DEV Community

Cover image for A Guide to Phoenix LiveView Assigns
Karol Słuszniak for AppSignal

Posted on • Originally published at blog.appsignal.com

A Guide to Phoenix LiveView Assigns

Phoenix LiveView lets you develop full-stack apps with client-side interactions while mostly avoiding cross-stack hassle. Assigns, managed by the LiveView socket, are a core tool for making that happen — allowing you to store, present, and update data effortlessly and efficiently. But as they do so much, assigns come with their own complexities and may backfire if misused.

Over the last three years, I've had the pleasure to work on multiple LiveView apps within multiple teams, so I know first-hand that assigns can feel like magic to many developers (myself included).

In this article, we're going to demystify LiveView assigns. We'll describe what LiveView assigns actually are in relation to popular frameworks, find out how assigns work in practice, and highlight some of the traps that one may easily fall into.

Note: Throughout this post, we're going to use assigns to refer to LiveView assigns — as opposed to EEx assigns, Plug assigns, or Phoenix Socket assigns.

Demystifying Phoenix LiveView Assigns

Let's start with an overview of what assigns actually are. Here's a basic example:

defmodule MyAppWeb.StatsLive do
  use MyAppWeb, :live_view
  alias MyApp.{Accounts, Notes}

  def mount(params, session, socket) do
    {:ok, assign(socket,
      note_count: Notes.get_note_count(),
      user_count: Accounts.get_user_count()
    )}
  end

  def render(assigns) do
    ~H"""
    Note count: <%= @note_count %>
    User count: <%= @user_count %>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

The above snippet shows how @note_count and @user_count are assigned initial values and then rendered as dynamic fragments among the static HTML that surrounds them. That's the concept of LiveView assigns in a nutshell.

Now let's take a deeper look, pointing out some similarities and differences between assigns and mainstream front-end development.

Let's get going!

LiveView Assigns Provide Dynamic Input

Assigns in LiveView build on top of those used by EEx as well as from "traditional" Phoenix. They are just input variables — placeholders for dynamic data provided from the outside.

Run the following code from iex:

iex> EEx.eval_string("Hello, <%= @name %>!", assigns: [name: "John"])
"Hello, John!"
Enter fullscreen mode Exit fullscreen mode

We can easily compare assigns to props from React, Vue, or any other web component library. This is especially true for stateless components that have recently switched to simple function syntax, like functional components in React. Consider this simple component:

defmodule MyAppWeb.MyComponents do
  def my_button(assigns = %{text: _, click: _}) do
    ~H"""
    <button class="some-class" phx-click={@click}>
      <%= @text %>
    </button>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

You can surely imagine yourself rewriting it in any web component library with a very similar code structure and volume. For the record, here's how similar it'd be in React:

function MyButton({ text, click }) {
  return (
    <button class="some-class" onClick={click}>
      {text}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component uses assigned input wherever it needs to, while the caller of my_button provides it like this:

<.my_button text="Click me!" click="some_event" />
Enter fullscreen mode Exit fullscreen mode

Easy enough, and no room for dilemmas. But that's just the start.

LiveView Assigns Manage State

For live views and live components, assigns grow from being a dumb input into a long-lived state. As such, they derive from Phoenix Channel assigns, and shift from props into state in the React nomenclature.

Views and components may opt to update their assigns from the inside to evolve their state. This is nothing fancy on its own, so — just like with props — we can easily find an equivalent in any web component framework.

What is fancy, however, is that in LiveView this happens on the server — taking advantage of Erlang processes to deliver low latency at scale.

To understand why this is great, let's take a look at the following example:

defmodule MyAppWeb.UserSubscriptionLive do
  use MyAppWeb, :live_view

  # ...

  def handle_event("pick_subscription", params, socket) do
    # encrypted session
    %{assigns: %{current_user: user}} = socket

    # CSRF-protected user input
    %{"plan" => plan} = params

    # ACID-safe database
    subscription = Subscriptions.create_subscription(user, plan)

    # non-public, secure APIs
    Payments.charge_user(user, subscription)

    # server-only background jobs
    Mailing.send_subscription_created_email(user, subscription)

    {:noreply, assign(socket, subscription: subscription})
  end
end
Enter fullscreen mode Exit fullscreen mode

Without any effort, we mix data from the session, user input, databases, APIs, background jobs, and PubSubs — in one place, untampered, and server-side rendered. Presenting the results is just as simple:

<%= unless @subscription do %>
  Pick your plan:

  <a href="#" phx-click="pick_subscription" phx-value-plan="basic">Basic ($4.99)</a>
  <a href="#" phx-click="pick_subscription" phx-value-plan="premium">Premium ($19.99)</a>
<% else %>
  You're currently subscribed to <%= format_plan(@subscription.plan) %> plan.

  <%= if @subscription.last_payment do %>
    You were last charged on <%= DateTime.to_date(@subscription.last_payment.inserted_at) %>.
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Imagine all the moving parts that it would take to produce and fully test the client-side equivalent of our @subscription assign. This is perhaps the biggest highlight of LiveView, stealing the spotlight from API layers, state managers, sagas, and browser-based test suites.

At the same time, state management is undoubtedly a challenging task, and keeping memory usage under control is one of our main concerns. This is especially true considering we're on the server, and all connected users will claim the memory needed to hold the assigns during their entire session.

We'll examine a specific use case along with its solution in a moment.

LiveView Assigns Fuel Change Tracking

If you think that assigns are "just" props and state, and the show is over, brace yourself.

You see, if you update a single React prop or piece of React state, the whole component will re-render. But LiveView assigns are different. The reason for this is HEEx — a templating engine that splits your template into static and dynamic parts, and then only re-evaluates dynamic parts to involve the changed assigns.

Since this happens on the server, it's only the actual changes that will ever take up bandwidth, saving you the effort needed to slim down JSON payloads in classic SPAs, e.g., by designing case-specific or flexible APIs. That's some cool out-of-the-box optimization.

Note: This feature is known by different names, including change tracking, reactivity, memoization, or observability. The approach towards it varies across JS frameworks — check out the above keywords regarding Angular, Ember, Knockout, or Svelte.

In React, we can achieve similar behavior in a more explicit way (so it'll serve as a great visual exercise) with useMemo. Consider the following live component:

defmodule MyAppWeb.UserHoroscope do
  use MyAppWeb, :live_component

  def render(assigns) do
    ~H"""
    <div>
      <strong>Horoscope for {format_full_name(@first_name, @last_name)} (born on {format_date(@birthday)}):</strong>
      {generate_horoscope(@birthday)}
    </div>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

Out of the box, it will only call format_date or get_horoscope when @birthday changes, but not when either @first_name or @last_name do (which may be extra useful if our generator that powers the horoscope becomes complex and the user name changes). The same goes for the other <%= ... %> snippets.

With React, we start off with the following naive component that runs all helper functions, regardless of changes:

function UserHoroscope({ firstName, lastName, birthday }) {
  return (
    <div>
      <strong>
        Horoscope for {formatFullName(firstName, lastName)} (born on{" "}
        {formatDate(birthday)}):
      </strong>
      {getHoroscope(birthday)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then, we'd extend it into the following shape with useMemo so that each helper is called only when its dependencies change:

function UserHoroscope({ firstName, lastName, birthday }) {
  const fullName = useMemo(() => {
    return formatFullName(firstName, lastName);
  }, [firstName, lastName]);

  const formattedBirthday = useMemo(() => {
    return formatDate(birthday);
  }, [birthday]);

  const horoscope = useMemo(() => {
    return generateHoroscope(birthday);
  }, [birthday]);

  return (
    <div>
      <strong>
        Horoscope for {fullName} (born on {formattedBirthday}):
      </strong>
      {horoscope}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: We're not saying React is the worst here. React focuses on optimizing historically slow DOM patching while assuming that modern JS is fast enough to re-evaluate typical rendering logic (ideal for a client-side library). LiveView goes the extra mile to save on server resources and bandwidth without sacrificing the dev experience — which works well for a compiler-backed server-side library.

Overall, change tracking is a powerful feature that does some really useful stuff under the hood while keeping our code clean. But it may also raise some ambiguity and doubts as far as assigns are concerned, and, if used without care, change tracking can definitely backfire performance-wise.

Taking LiveView Assigns for a Spin

Now that we know multiple concepts are mixed in with assigns, we see how they may actually not be as simple as they look in the code.

LiveView docs have a dedicated assigns and HEEx guide, so that's the first place you should look for answers and pointers. The module page for the template engine gives some extra insight.

But, sooner or later (and I'd put my bets on sooner considering the early stage of LiveView), that isn't enough. Let's look at how to approach LiveView to:

  • Play with it and boost your confidence
  • Debug and fix suspicious behavior in a real app
  • Check for optimizations or regressions in new LiveView releases

Caveman Debugging in LiveView

The first technique that you may want to employ involves returning to the very roots of debugging. You'll use a battle-proven, highly sophisticated technique that's been hiding in the darkest corners of the internet under numerous inspiring names like 'caveman debugging'.

The idea is to rely on the fact that HEEx only re-evaluates a subset of code blocks and to output a timestamp as close as possible to the place you want to inspect in the template.

To try it out, generate a sample live resource for testing in your Phoenix 1.6 app:

mix phx.gen.live Notes Note notes name:string content:text
Enter fullscreen mode Exit fullscreen mode

Apply the printed instructions and navigate to /notes. Then add the following inspect/1 calls to your index.html.heex:

<%= inspect({Time.utc_now(), :above_notes}) %>
<table>
  <!-- CUT (table header) -->
  <tbody id="notes">
    <%= for note <- @notes do %>
      <tr id={"note-#{note.id}"}>
        <td><%= inspect(Time.utc_now()) %></td>
        <td><%= inspect({Time.utc_now(), note.name}) %></td>
        <td><%= inspect({Time.utc_now(), note.content}) %></td>
        <!-- CUT (actions cell) -->
      </tr>
    <% end %>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Now try adding and removing some notes. In the default generated live resource, you should notice that the :above_notes timestamp updates together with all the per-row ones when creating or editing notes, but not when deleting them.

This hints that something different is happening with the live view and its assigns for the delete case. And rightfully so. When you look at the code, FormComponent uses push_redirect/2 upon a save, causing the whole index to reload, while Index uses assign/2,3 upon deletion to only update that specific assign.

If you want to avoid the reload, it's as simple as replacing push_redirect/2 in FormComponent with push_patch/2, plus reloading the @notes assign in Index like this:

defp apply_action(socket, :index, _params) do
  socket
  |> assign(:page_title, "Listing Notes")
  |> assign(:notes, list_notes())
  |> assign(:note, nil)
end
Enter fullscreen mode Exit fullscreen mode

Voilà: your first performance tweak!

You may also have noticed that all per-row timestamps re-render when you create, edit, and delete notes — regardless of whether a specific note is affected or not. We'll get back to this later.

Socket Inspection

Sometimes, it's useful to see what LiveView pushes down the wire. This isn't so easy to visually tie to specific places on the page. But it allows us to inspect what is getting updated, and when, without template changes, and — this is especially useful — to see payload size and structure.

In order to inspect a socket:

  1. Visit your live view in Chrome.
  2. Open Developer Tools.
  3. Switch to the Network tab.
  4. Optionally filter by the WS type.
  5. Choose the websocket?_csrf_token=... name.
  6. Switch to the Messages tab.
  7. Choose the message that you're interested in.

Here's how it might look:

Chrome devtools showing the LiveView rendering payload for two notes.

When working with two notes, the highlighted piece of inbound payload is responsible for filling the listing. This confirms that the entire listing is re-rendered, regardless of operation.

Note: You may find it more handy to ask LiveView's JS client to log updates to the console, by calling liveSocket.enableDebug() in it.

In the end, having insight into the raw payload can lead you to some case-specific optimizations, such as:

  • Restructuring the assigns (e.g., by breaking apart large or nested ones)
  • Using live components (to ensure some assigns are tracked separately)
  • Sending some data to JavaScript hooks as binary (by switching to Phoenix.Channel)

Debugging in Production with AppSignal

The caveman technique is great for a single developer working on an application that hasn't been pushed to production. However, if you have an app in production with live users, you may want to take a look at AppSignal for monitoring your application performance and checking for errors in production.

Adding AppSignal to an existing application takes a few seconds, and you can track LiveView errors from there.

Here's an example of the AppSignal dashboard collecting data from a sample application:

AppSignal Screenshot

Wrap Up

In this post, we started off by demystifying LiveView assigns, before touching on some cross-over between assigns and mainstream front-end development. Finally, we explored caveman debugging in LiveView and socket inspection.

LiveView combines a lot of functionality in assigns, including some that's just as clever and useful as it is implicit and complex.

The key is to get a grasp on the key concepts, which are all relatable to what we can find in client-side frameworks. And, considering the framework's young age, to have a fallback solution for when existing resources can't help — a way to dig into it on your own.

Next up in this two-part series, we'll look at some common pitfalls when it comes to LiveView assigns.

Until then, happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Top comments (0)