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:

  1. Defining a new data assign called :show_dialog to hold the state

  2. 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:

defmodule Example do
  use Surface.LiveView

  data show_dialog, :boolean, default: false

  def render(assigns) do
    ~F"""
    <Dialog title="Alert" show={@show_dialog} hideEvent="hide_dialog">
      The <b>Dialog</b> is a stateless component. All event handlers
      had to be defined in the parent <b>LiveView</b>.
    </Dialog>

    <Button click="show_dialog">Click to open</Button>
    """
  end

  def handle_event("show_dialog", _, socket) do
    {:noreply, assign(socket, show_dialog: true)}
  end

  def handle_event("hide_dialog", _, socket) do
    {:noreply, assign(socket, show_dialog: false)}
  end
end

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:

defmodule Example do
  use Surface.LiveView

  def render(assigns) do
    ~F"""
    <div>
      <Dialog title="Alert" id="dialog">
        The <b>Dialog</b> is now a stateful component. All event handlers
        were defined in the component itself. <b>Cool!</b>
      </Dialog>

      <Button click="show_dialog" kind="is-info">Click to open the new dialog</Button>
    </div>
    """
  end

  def handle_event("show_dialog", _, socket) do
    Dialog.show("dialog")
    {:noreply, socket}
  end
end