LiveView rendering pitfalls and how to avoid them

A road sign with two arrows pointing in opposite directions mounted above a red, diamond-shaped sign.

Phoenix LiveView empowers Elixir developers to write rich, interactive web applications rendered entirely on the server. One of the tentpoles of its approach to ensure application responsiveness is to send only very small HTML changes (“diffs”) across a persistent WebSocket connection. Put another way, LiveView strives to send only what has changed, when it changes.

There are some subtle ways to accidentally undermine LiveView’s ability to track changes and, in those cases, LiveView will often fall back to the worst-case scenario: re-rendering a larger portion of HTML than is necessary and sending it across the wire, even if most of it is unchanged. In some circumstances, it may even send diffs when nothing has changed at all!

These inefficiencies are not always immediately apparent, especially when testing the application locally, because the application will appear to display correctly and function as intended.

The LiveView docs outline a couple of pitfalls in this regard. This article will explore these examples and a couple others, and demonstrate how to detect and resolve them.

Least efficient: A view helper function

To render a simple piece of markup, it’s common to leverage basic language constructs—a view helper function that returns a string.

# In the view
<%= hello_function(@greeting, @person) %>

# The function
def hello_function(greeting, person) do
  "Why #{greeting} there, #{person.first_name} #{person.last_name}!"
end

This function is reevaluated whenever there are changes to either @greeting or @person. LiveView can’t infer what might have changed within the contents of the string, so it evaluates the entire function and returns the full string. Even if only a single value is changed, like @person.last_name, the resulting JSON diff sent over the WebSocket connection looks like this:

"Why hello there, General Grevious!"

In this particular example, the content is so small that it is probably well suited as a helper function, but it is clear that this approach won’t scale well for larger, more complex UI components.

More efficient: A function component without change tracking

LiveView 0.16 introduced the concept of function components as a first-class way to define and render reusable markup. To conform to this API, let’s rewrite the welcome message as a 1-arity function that accepts an assigns argument and returns a HEEx template.

def hello_component(assigns) do
  ~H"""
  Why <%= @greeting %> there, <%= @person.first_name %> <%= @person.last_name %>!
  """
end

# Example only - do not actually do this
<%= hello_component(%{greeting: @greeting, person: @person}) %>

Now, after the value of @person.last_name changes, the diff looks like:

{ "0": "hello", "1": "General", "2": "Grevious" }

It’s better, but it still contains extraneous data. Even though only the last name changed, all three placeholder values had to be retransmitted to the browser. Inspecting the assigns argument to the function at the moment the change occurs reveals:

%{
  greeting: "hello",
  person: %{first_name: "General", last_name: "Grevious"}
}

All the assigns are present, so this component has everything it needs to successfully render its template. However, there is no way for the component to know which assigns have changed from the previous time the component was rendered.

Function components are completely stateless, so the only thing the component knows is that at least one of its passed-in assigns changed, but it can’t tell which ones. Therefore, the HEEx template is forced to reevaluate every interpolated value.

Here are some more examples of components and views that will render correctly, but result in the same large diffs because change tracking cannot occur:

# Avoid these
<.hello_component {%{greeting: @greeting, person: @person}} />
<%= render WelcomeView, "hello.html", greeting: @greeting, person: @person %>

Most efficient: A function component with change tracking

Function components can take advantage of special syntax in HEEx templates when it comes time to render them. In addition to being visually similar to HTML, it also offers the greatest compatibility with change tracking.

# Do this!
<.hello_component greeting={@greeting} person={@person} />

So, how does the diff look now?

{2: "Grevious”}

It can’t get much smaller than that! The function component is completely stateless, so that means that the changes must be accounted for via some external mechanism. Let’s inspect the assigns argument:

%{
  __changed__: %{person: %{first_name: "General", last_name: "Kenobi"}},
  greeting: "hello",
  person: %{first_name: "General", last_name: "Grevious"}
}

Ah-ha! Here is a first look at the __changed__ field. This is a special key LiveView keeps alongside the application’s assigns in order to track what values have changed between successive renders.

By using the proper syntax, __changed__ has been automatically derived from the state of the LiveView and passed down to the function component. Notably, it only contains information about the person, since that is the only assign that changed in the LiveView. Note that it contains the previous person’s data, which will be compared to the current data in the assigns in order to generate the diff.

Ultimately, when assigns.__changed__ is present, the HEEx template takes its data into account during evaluation, so only the smallest changes are returned. By using the correct syntax for calling function components, we can ensure that changes LiveView tracks will correctly trickle down to the rendered components that care about those changes.

Pitfall: Direct variable access

Before wrapping up, there is one common pitfall addressed in the LiveView documentation, but is still a very common sight in HEEx templates (emphasis mine):

Generally speaking, avoid accessing variables inside LiveViews, as code that access variables is always executed on every render. This also applies to the assigns variable.

It’s okay to reference variables that are bound inside blocks—such as for list comprehensions, and component slots that use let—but avoid other variable access. This warning is not about the size of diffs, but about the frequency of code evaluation within the template.

As previously discussed, the only mechanism by which HEEx can track changes is to introspect __changed__. Any local variable referenced in the template is definitely not tracked in __changed__, because it is not an assign. There is no opportunity to decide what to do about the variable, so there is only one logical solution. Any references to a local variable must be reevaluated on every execution of the template, even when its value is ultimately unchanged from previous evaluations.

It should be clear now why referencing assigns is especially dangerous. Any time that a single value in the assigns map changes, every reference to the assigns variable must be completely reevaluated and diffed, even if the rendered output would not change as a result.

The following is a large–but not exhaustive–list of examples of template code to avoid unnecessary reevaluations. Please, have some empathy for the machine!

# Please avoid these incantations
<%= assigns[:greetings] %>
<%= assigns.greetings %>
<.hello_component {assigns} />
<.hello_component greeting={assigns[:greeting]} person={assigns[:person]} />
<%= hello_component(assigns) %>
<%= render WelcomeView, "hello.html", assigns %>

Conclusion

LiveView does a lot of heavy lifting to ensure that changes to HEEx templates are efficiently tracked and transmitted to the client. It’s the developer’s responsibility to understand how HEEx decides what needs to be evaluated at any given moment, and how stateless component functions take advantage of the stateful nature of parent LiveView processes as part of this decision.

In general, as long as the function component syntax is utilized, and templates contain no references to any local variables (especially assigns), you can avoid many rendering inefficiencies.

When in doubt, don’t be afraid to add an IO.inspect to see how and when components are being rendered. In the end, they’re all just functions!

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box