Skip to content
stoic man
January 21, 2025

My experience with Phoenix LiveView

Some takeaways from my time with Phoenix LiveView

I've been experimenting with Phoenix LiveView for a while now. I have a net-positive experience but can't bring myself to love it or use it for my production projects.

It's an exciting technology where things work, but they don't quite feel right.

JavaScript interop

The biggest issue for me is the interop with JavaScript.

At some point, no matter what your initial expectations were, you need to bridge this gap and use JavaScript. That's where things get hairy.

Initially, you try to make things work with JS hooks, but this quickly becomes difficult to work with. Here's an example from the LiveBeats repo. The markup can very easily get out of sync with JavaScript. Maintainability is also an issue.

Some people pull Alpine, or libraries like LiveSvelte to help with this. But this seems like a weird choice to me. You're niching down your stack even further, and the initial quest to reduce front-end complexity ends up increasing it. The no-js-needed premise is gone, and along with it, you're left with a weird hybrid stack.

You get a bit of styling here, a little bit of front-end logic there, and a little bit of front-end state management over there. All of this, mushed together in the same codebase with the backend. It works, but stepping away for some time, and returning for a new feature, doesn't feel right. You have to reorient yourself, and remember how all this plumbing works.

JavaScript interop should be a first-class citizen and not an afterthought.

Components

The ecosystem is obviously small. That's, of course, expected when you're picking Elixir.

But now, when your front-end uses LiveView and HEEx, you're closing the door to a lot of good stuff out there.

Authoring components in HEEx can be very awkward. Here's part of the input component from the Phoenix skeleton.

It's a god-component that accepts a lot of options, and then does pattern matching to render the appropriate input.

Untitled-1
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
 
attr :type, :string,
  default: "text",
  values: ~w(checkbox color date datetime-local email file month number password
             range search select tel text textarea time url week)
 
attr :field, Phoenix.HTML.FormField,
  doc: "a form field struct retrieved from the form, for example: @form[:email]"
 
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
 
attr :rest, :global,
  include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
              multiple pattern placeholder readonly required rows size step)
 
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
  errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
 
  assigns
  |> assign(field: nil, id: assigns.id || field.id)
  |> assign(:errors, Enum.map(errors, &translate_error(&1)))
  |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
  |> assign_new(:value, fn -> field.value end)
  |> input()
end
 
def input(%{type: "checkbox"} = assigns) do
  assigns =
    assign_new(assigns, :checked, fn ->
      Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
    end)
 
  ~H"""
  <div>
    <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
      <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
      <input
        type="checkbox"
        id={@id}
        name={@name}
        value="true"
        checked={@checked}
        class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
        {@rest}
      />
      {@label}
    </label>
    <.error :for={msg <- @errors}>{msg}</.error>
  </div>
  """
end
 
def input(%{type: "select"} = assigns) do
end
 
def input(%{type: "textarea"} = assigns) do
end
 
# etc..

From an Elixir POV, it's fantastic, I love that we can do this. But from a front-end perspective, it doesn't excite me or give me confidence. I can't do a quick prototype, or commit to a long-term project when my re-usable components have to be written in this manner.

There are some third-party component libraries (mostly paid), but they are not as polished as the ones you get with React or Vue.

Code organization

No back-end framework, to my knowledge, provides a good solution for organizing your front-end code, and Phoenix is no exception.

When you start a new Phoenix project, you get all your re-usable components in a core-components.ex file. How you organize, grow, and maintain this file is up to you.

I agree that this is something unique to each application, but I can't help but feel that the lack of a good solution is a missed opportunity.

When you add LiveComponents to the mix, things get more annoying. Moving stuff around also has the negative of having to update the module name if you want to follow Elixir's naming conventions.

live_session and on_mount

Let's move on. live_session lets you share the state with multiple LiveViews. So let's say we have this in our router:

router.ex
scope "/", MyAppWeb do
  pipe_through [:browser, :require_authenticated_user]
 
  live_session :authenticated, on_mount: {MyAppWeb.UserAuth, :ensure_authenticated} do
    live "/dashboard", DashboardLive
    live "/settings", SettingsLive
  end
end

Before we render the DashboardLive or SettingsLive, we double-check that the user is authenticated in the ensure_authenticated function.

user_auth.ex
def on_mount(:ensure_authenticated, _params, session, socket) do
  if socket.assigns[:current_user] do
    {:cont, socket}
  else
    {:halt, redirect(socket, to: ~p"/login")}
  end
end

This looks good on the surface but gets annoying when you have to do multiple checks.

router.ex
scope "/", MyAppWeb do
  pipe_through [
    :browser,
    :require_authenticated_user,
    :require_email_verified,
    :require_unlocked_account,
    :require_organization
  ]
 
  # Controllers need to be used for some things
  post "/organization/select", OrganizationSessionController, :update
  post "/users/clear_sessions", UserSessionController, :delete_other_sessions
 
  live_session :authenticated_with_organization,
    on_mount: [
      {UserAuthLive, :ensure_authenticated},
      {UserAuthLive, :ensure_current_organization},
      {UserAuthLive, :ensure_email_verified},
      {UserAuthLive, :redirect_if_locked},
      {LiveHelpers, :default}
    ] do
    live "/", DashboardLive, :home
 
    # User settings
    live "/users/settings", UserSettings.IndexLive, :edit
    live "/users/settings/security", UserSettings.SecurityLive, :security
    live "/users/settings/confirm_email/:token", UserSettings.IndexLive, :confirm_email
 
    # Organization management
    live "/organization", Organization.IndexLive, :index
    live "/organization/invitations", Organization.InvitationsLive, :index
  end
end

This becomes a headache for me. I understand why you need to do things twice, but it feels awkward, and you can easily mess it up.

In fact, I'm certain that there might be a better way to do this, but if I have to think about it that much, then it's not a good API.

You still need controllers

When I was exploring LiveView, I was hoping that I could drop the controllers and go full-on with this new paradigm, similar to React Server Components.

Essentially, have a single file that does everything for that page.

Unfortunately, LiveView can't do it; you still need controllers. For example, you need to POST to a controller to set the session.

Here's an example from the authentication generator. You create the user in LiveView, but for the session, you need to hit a controller.

user_registration_live.ex
defmodule MyAppWeb.UserRegistrationLive do
  use MyAppWeb, :live_view
 
  alias MyApp.Accounts
  alias MyApp.Accounts.User
 
  def render(assigns) do
    ~H"""
    <div class="mx-auto max-w-sm">
      <.header class="text-center">
        Register for an account
        <:subtitle>
          Already registered?
          <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
            Log in
          </.link>
          to your account now.
        </:subtitle>
      </.header>
 
      <.simple_form
        for={@form}
        id="registration_form"
        phx-submit="save"
        phx-change="validate"
        phx-trigger-action={@trigger_submit}
        # Here ----------v
        action={~p"/users/log_in?_action=registered"}
        method="post"
      >
        <.error :if={@check_errors}>
          Oops, something went wrong! Please check the errors below.
        </.error>
 
        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />
 
        <:actions>
          <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
 
  def mount(_params, _session, socket) do
  # omitted
  end
 
  def handle_event("save", %{"user" => user_params}, socket) do
    # And here ----------v
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        {:ok, _} =
          Accounts.deliver_user_confirmation_instructions(
            user,
            &url(~p"/users/confirm/#{&1}")
          )
 
        changeset = Accounts.change_user_registration(user)
        {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
 
      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
    end
  end
 
  def handle_event("validate", %{"user" => user_params}, socket) do
    # omitted
  end
 
  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    # omitted
  end
end

I understand the technical limitations, but it's a bummer.

I was hoping for a more holistic approach. It feels weird that I do a mutation in LiveView and then a side effect in the controller.

No matter how you slice it, it's suboptimal.

GenServer centric API

Finally, the API is very GenServer centric. I believe this is intended, as the maintainers want to show you that it's nothing more than a GenServer under the hood.

In my opinion, this is a mistake, and it hurts the adoption. Imagine trying to convince your front-end team to evaluate LiveView, and you show them this:

Untitled-1
def handle_event("increment", _params, socket) do
  {:noreply, assign(socket, count: socket.assigns.count + 1)}
end

What is this :noreply? Is this a side-effect?

Say we generate some boilerplate with mix phx.gen.live Things Thing things name:string description:text and look at the form_component.ex.

On version 1.7.18 I get the following:

Untitled-1
@impl true
def handle_event("validate", %{"thing" => thing_params}, socket) do
end
 
def handle_event("save", %{"thing" => thing_params}, socket) do
end
 
defp save_thing(socket, :edit, thing_params) do
end
 
defp save_thing(socket, :new, thing_params) do
end
 
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})

What is this send(self(), {__MODULE__, msg}) and why is only one handle_event having @impl: true? Why do we need this ceremony for a simple form?

For what it matters the missing @impl true is a mistake, but doesn't break anything.

If I hadn't read Elixir in Action before picking up Phoenix, I would have quit in the first 10 minutes. The API should be simpler, there's no need for the plumbing to be visible.

Conclusion

LiveView is a great tool for internal applications or applications without lots of interactions. You can do cool things and ship them quickly. It's honestly amazing that our solutions are not just React, Vue, or Angular. If it doesn't suit my needs, there are 100 developers who might adore it.

But it's not the silver bullet some people make it out to be.

I wanted to write this post to share my thoughts, and balance the discussion, as most of the posts I've read don't mention some of the issues I've faced.


For the past weeks, I've been toying with Inertia & its Phoenix adapter.

I have to say, it's the most fun I've had coding for a while. I get the things I love about Elixir, and then throw in some React. If you're looking for a way to get the best of both worlds, I highly recommend it. Feel free to reach out if you have any questions, I'll write about it soon as well.