I have worked with Phoenix LiveView and Surface-UI for about a year; I would like to share some of the things I learned the hard way.
Intro
Marlus Saraiva introduced me to a concept from the React community called single source of truth
.
There should be a single
source of truth
for any data that changes in a React application. Usually, the state is first added to the component that needs it for rendering. Then, if other components also need it, you can lift it up to their closest common ancestor.
full reference - Lifting State Up
Once you understand this concept, you will see it over and over again as a pattern.
Setup
Imagine an application called Dummy that has a Calculator for Temperature, where you input values in Celsius and show them in Fahrenheit.
The application will be like this:
First thing first, we should add a new path to the Router for our live Calculator.
router.ex
scope "/", Dummy do
pipe_through(:browser)
live("/", Calculator)
end
Now the route is created, we can work on the live_view. We can ignore the params
and sessions
in mount/3, and just assign the default Fahrenheit value through the socket.
In the render/1 template, we will call TemperatureInput live_component
and pass the id and the Fahrenheit value. Finally we will add handle_info/2.
live/calculator.ex
defmodule DummyWeb.Calculator do
use DummyWeb, :live_view
alias DummyWeb.TemperatureInput
@impl true
def mount(_params, _session, socket), do: {:ok, assign(socket, :fahrenheit, 0)}
@impl true
def render(assigns) do
~H"""
<main class="hero">
<.live_component module={TemperatureInput} id={"celsius_to_fahrenheit"} fahrenheit={@fahrenheit}/>
</main>
"""
end
@impl true
def handle_info({:convert_temp, celsius}, socket) do
fahrenheit = to_fahrenheit(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
defp to_fahrenheit(celsius) do
String.to_integer(celsius) * 9 / 5 + 32
end
defp to_celsius(fahrenheit) do
(String.to_integer(fahrenheit) - 32) * 5 / 9
end
end
Now for the live_component.
live/temperature_input.ex
defmodule DummyWeb.TemperatureInput do
use DummyWeb, :live_component
def render(assigns) do
~H"""
<div>
<div class="row">
<.form let={f} for={:temp} phx-submit="to_fahrenheit" phx-target={@myself} >
<div>
<%= label f, "Enter temperature in Celsius" %>
<%= text_input f, :celsius %>
</div>
<div>
<%= submit "Submit" %>
</div>
</.form>
</div>
<p>temperature in Fahrenheit: <%= @fahrenheit %></p>
</div>
"""
end
def handle_event("to_fahrenheit", %{"temp" => %{"celsius" => celsius}}, socket) do
send(self(), {:convert_temp, celsius})
fahrenheit = to_fahrenheit(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
defp to_fahrenheit(celsius) do
String.to_integer(celsius) * 9 / 5 + 32
end
defp to_celsius(fahrenheit) do
(String.to_integer(fahrenheit) - 32) * 5 / 9
end
end
Looking into the live_component, it only has a form with an input field where the user types a value in Celsius degrees and clicks the submit button to send this event to the server, which has a handle_event/3, and does two things:
- send a message to the parent(the live_view).
- calculate the Fahrenheit temperature and assign it through the socket.
We are using two LiveView phx-* bindings:
-
phx-submit
- it will send the form to the handle event called to_fahrenheit. -
phx-target
- it tells who should handle the event. The default behaviour is always the live_view, otherwise we can set the target. Using @myself to set the target is our live_component.
You have most likely noticed that the handle_info in live_view and the handle_event in live_component have the same code. The only difference between them is the send()
function to update the Fahrenheit value.
The Issue
We should avoid the duplicity of logic and in our example the formula to convert Celsius to Fahrenheit is duplicated in both the live_view and the component.
To demonstrate what can go wrong, we will use :timer.sleep
to change the formula in just the live_component.
Change the logic in live_component.
live/temperature_input.ex
def handle_event("to_fahrenheit", %{"temp" => %{"celsius" => celsius}}, socket) do
send(self(), {:convert_temp, celsius})
# wrong formula
fahrenheit = to_celsius(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
live/calculator.ex
def handle_info({:convert_temp, celsius}, socket) do
:timer.sleep(4000)
fahrenheit = to_fahrenheit(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
Change the logic in live_view.
live/temperature_input.ex
def handle_event("to_fahrenheit", %{"temp" => %{"celsius" => celsius}}, socket) do
send(self(), {:convert_temp, celsius})
fahrenheit = to_fahrenheit(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
live/calculator.ex
def handle_info({:convert_temp, celsius}, socket) do
:timer.sleep(4000)
# wrong formula
fahrenheit = to_celsius(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
The Solution
Avoid keeping the logic in more than one place. In this case we decided to keep the logic in the live_view only but it didn't have to be in the live_view. The goal is to never duplicated the logic.
live/temperature_input.ex
def handle_event("to_fahrenheit", %{"temp" => %{"celsius" => celsius}}, socket) do
send(self(), {:convert_temp, celsius})
{:noreply, socket}
end
live/calculator.ex
def handle_info({:convert_temp, celsius}, socket) do
fahrenheit = to_fahrenheit(celsius)
{:noreply, assign(socket, :fahrenheit, fahrenheit)}
end
Wrapping up
Keep the logic where the data is defined as a single source of the truth to avoid future headaches and difficult to fix bugs.
Thank you all, and I hope you enjoy and have fun. So stay tuned for what is coming next.
And a special thanks to Adolfo Neto, Cristine Guadelupe, Mike Kumm, Willian Frantz for review my blog post.
Top comments (3)
Great explanation, thanks for sharing, it really makes a lot of sense to avoid having duplicates around your codebase.
Nice demo indeed. The liveview and the live-component are in the same process but each has his own state, and by design changing the assigns makes it render - same in React with the state - so a double rendering is a problem.
Good point and indeed. Also, everything could be worse if you have more
live_components
depending on the samestate
which is coming from theparent
(in most cases thelive_view
).