LiveView: shallow states or deep states?

I’ve been building some projects using Phoenix LiveView, and have read the source code of quite a few more projects made by other people (thanks, Phoenix Phrenzy contest!).

One thing I am seeing, is essentially a dichotomy in two approaches to working with state and interactivity, when working with LiveView-powered applications that are essentially ‘single-page’ (i.e. rather than one or multiple pages with multiple independent LiveView snippets, the whole interface is driven by LiveView).

Approach 1: “Shallow State”

In this approach, we have many nearly-independent LiveViews/ (stateful) LiveComponents that mostly work with their own socket.assigns directly.

Because socket.assigns is a map, and because all of these components are stateful, the data that is stored and the communication between them is mostly ad-hoc, i.e. non-normalized.

Approach 2: “Deep State”

In this approach, we have a single LiveView with potentially multiple nested (stateless) LiveComponents that contains the state in a (deeply) nested map in its socket.assigns. Most or all changes that come in travel through this single LiveView’s handle_event and potentially update (some nested part of) its socket.assigns. The socket.assigns consists of one or a few Elixir Structs, which makes it very clear what kind of data to expect, i.e. the data is normalized.

Using this approach, it is possible to extract most of the domain-logic out to its own separate module(s), making the code easy to test. Of course, you do end up with more code since you add a/some layer(s) of abstraction between the LiveView-module and the place where the actual state-transition logic happens.

This kind of architecture has strong similarities with first-order functional reactive programming (also known as ‘the Elm Architecture’(TEA)).

Now, I don’t know which approach is better suited for what kind of situations, but I find it very interesting to see that there are these two sort-of opposed kinds of working with LiveView going on, and I’d love to start a discussion on the pros and cons! :grinning:


Hi everyone! Are there more people who have given this some thought, or am I too early with this discussion? :upside_down_face:

Let me just ask you, then: In what way do you manage state in your LiveView application? :grin:

I’m just learning LiveView through the PragStudio course which is excellent so far.

The temporary_assigns feature makes it feel like the framework is pushing users towards the shallow state style. Can you indicate that a nested map key is a temporary?

I found it difficult to track which assigns are available within the template without some declaration of the data structure, so I’ve adopted a convention of an inner Assigns module within the LiveView module that declares a @type and provides functions for managing the state:

defmodule MyApp.MyViewLive do
  use MyApp, :live_view

  alias MyApp.SomeContext
  alias MyApp.SomeContext.SomeSchema

  defmodule Assigns do
    @type t :: %{
            items: [SomeSchema.t()],
            filter: String.t()

    @spec new([SomeSchema.t()]) :: t()
    def new(initial_data) do
        items = initial_data
        filter = ""

    def temporaries() do
      [items: []]

    def with_filters(socket, items, filter) do
      socket |> assign(items: items, filter: filter)

  @impl true
  def mount(_params, _session, socket) do
    initial_data = SomeContext.get_some_data()
    socket = assign(socket,
    {:ok, socket, temporary_assigns: Assigns.temporaries()}

  def render(assigns) do
     ~L""" access @items etc here """

  def handle_event("some_event", %{"filter" => filter}, socket) do
    data = SomeContext.get_some_data(filter)
    socket = socket |> Assigns.with_filters(data, filter)
    {:noreply, socket}

For the relatively small examples I’ve followed so far, this has been helpful to allow me to treat the Socket.assigns map as a data structure with well defined operations. Looking forward to some more complex examples with Components :slight_smile: