State management
When designing a new component, one decision that has to be made is where to keep its state. Phoenix LiveView provides different ways to handle state depending on the type of the component you're using.
Let's take a closer look at each one of them.
Functional components
State management in functional components is quite simple. After all, there's no state to be
managed. It works as just like a pure function. You define properties that will be merged into
the assigns
, the assigns
will be passed to the render/1
function and that's it. You cannot
define events that can change any of the assigned values. If you want to do that, you'll have to
change the values passed as properties in the parent component, forcing the render/1
function
to be called again with the updated values.
defmodule Button do
use Surface.Component
prop click, :event
prop kind, :string, default: "is-info"
slot default
def render(assigns) do
~F"""
<button class={"button #{@kind}"} :on-click={@click}>
<#slot />
</button>
"""
end
end
Handling state with LiveView
Consider the following Dialog component:
defmodule Dialog do
use Surface.Component
prop title, :string, required: true
prop show, :boolean, required: true
prop hideEvent, :event, required: true
slot default
def render(assigns) do
~F"""
<div class={"modal", "is-active": @show}>
<div class="modal-background" />
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{@title}</p>
</header>
<section class="modal-card-body">
<#slot />
</section>
<footer class="modal-card-foot" style="justify-content: flex-end">
<Button click={@hideEvent}>Ok</Button>
</footer>
</div>
</div>
"""
end
end
The Dialog
above is a stateless component, i.e. it doesn't own its state and all
state handling must be done in the parent LiveView
by:
-
Defining a new
data
assign called:show_dialog
to hold the state -
Define the related
handle_event/3
callbacks to show/hide the dialog
Here's our dialog in action along with the parent LiveView's code:
Alert
Notice that even the "hide_dialog"
event which is dispatched by the dialog's
internal "Ok" button had to be defined in the live view.
One problem that might arise with this approach is that, as the parent live view gets larger holding more children with more complex states and events, a lot of code needs to be written in the live view to manage the state of each individual component.
Handling state with LiveComponent
In the last section, we saw that having lots of event handlers in a single LiveView might not be desired. One way to tackle this problem is by using a stateful LiveComponent instead. The great thing about live components is that they can handle their own state, consequently, we can move all component's related event handlers to the component itself.
That sounds really great but it raises a question. If the parent doesn't own the dialog's state anymore, how can the dialog be opened by the parent?
Introducing send_update/2
The LiveView documentation states that "send_update/2 is useful for updating a component that entirely manages its own state, as well as messaging between components."
That's exactly what we need! We can use send_update/2
to tell the dialog to update
itself, setting the :show
assign to true
:
def handle_event("show_dialog", _, socket) do
send_update(Dialog, id: "dialog", show: true)
{:noreply, socket}
end
Although calling send_update/2
from the parent view is a valid solution,
from the design perspective, explicitly setting :show
might not be ideal.
Remember that the fact we need to change the :show
assign in order to
show/hide the dialog is an implementation detail. Leaking internal details
of the state is problematic. Any change in the shape of the state might break
our code in many different places. Maybe for a simple case like our show/hide
that wouldn't be a big issue, but for more complex actions that update multiple
assigns, maintaining those actions in sync may become a nightmare. The solution,
however, is quite simple, we can define a public function show/1
in the Dialog
module to encapsulate the changes in the state.
Here's the updated version of our Dialog
component:
defmodule Dialog do
use Surface.LiveComponent
prop title, :string, required: true
data show, :boolean, default: false
slot default
def render(assigns) do
~F"""
<div class={"modal", "is-active": @show} :on-window-keydown="hide" phx-key="Escape">
<div class="modal-background" />
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{@title}</p>
</header>
<section class="modal-card-body">
<#slot />
</section>
<footer class="modal-card-foot" style="justify-content: flex-end">
<Button click="hide" kind="is-info">Ok</Button>
</footer>
</div>
</div>
"""
end
# Public API
def show(dialog_id) do
send_update(__MODULE__, id: dialog_id, show: true)
end
# Event handlers
def handle_event("show", _, socket) do
{:noreply, assign(socket, show: true)}
end
def handle_event("hide", _, socket) do
{:noreply, assign(socket, show: false)}
end
end
As you can see, the dialog's state is now opaque to the parent live view and any change to the internal state should only be performed through the component's public API.
Let's take a look at our new dialog in action along with the parent's live view code:
Alert