DEV Community

Cover image for Single source of truth with Phoenix LiveView
Herminio Torres
Herminio Torres

Posted on • Edited on

Single source of truth with Phoenix LiveView

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:

Celsius Input Form

Convert Celsius in Fahrenheit

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
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode


live/calculator.ex

def handle_info({:convert_temp, celsius}, socket) do
  :timer.sleep(4000)

  fahrenheit = to_fahrenheit(celsius)

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

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
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode


live/calculator.ex

def handle_info({:convert_temp, celsius}, socket) do
  fahrenheit = to_fahrenheit(celsius)

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

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)

Collapse
 
wlsf profile image
Willian Frantz

Great explanation, thanks for sharing, it really makes a lot of sense to avoid having duplicates around your codebase.

Collapse
 
ndrean profile image
NDREAN

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.

Collapse
 
herminiotorres profile image
Herminio Torres

Good point and indeed. Also, everything could be worse if you have more live_components depending on the same state which is coming from the parent(in most cases the live_view).