left arrow Back to posts

How we build forms in LiveView + LiveSvelte

Anthony Accomazzo
@accomazzo
10 min read

We're Sequin, an open source message stream built on Postgres. We have a console for managing tables and streams, and that console is built with LiveView + LiveSvelte.

We wrote previously about how much we enjoyed using LiveView with LiveSvelte. Since then, we've open sourced a lot of LiveView/LiveSvelte code. I thought it would be a good time to share the design patterns that have emerged with this stack so far. I'll focus primarily on how we write our forms.

As an example, let's look at the form for databases:

This form looks simple, but hides a lot of moving parts. We have features like:

  • A dialog that will pop up that you can use to paste in a full database URL.
  • Setup instructions that change based on how you've populated form elements.
  • A ton of validation when you submit, with error messages that can appear under inputs or as global errors.

And as with all our forms, we want to use the same form for both creates and updates.

The DatabasesLive.Form LiveView

Each Ecto model in our system corresponds (roughly) to a Form module. While that can be a LiveComponent, we'll make that a LiveView where possible.

Under lib/sequin_web/live/databases/form.ex is a LiveView:

defmodule SequinWeb.DatabasesLive.Form do
  @moduledoc false
  use SequinWeb, :live_view

In mount/3, we setup our initial state. Like most apps, creates happen at /databases while edits happen at /databases/:id. That means we bootstrap initial state based on if the form is for a create or an update:

  
  @impl Phoenix.LiveView
  def mount(params, _session, socket) do
    id = Map.get(params, "id")

    case fetch_or_build_database(socket, id) do
      {:ok, database} ->
        socket =
          socket
          |> assign(
            is_edit?: not is_nil(id),
            show_errors?: false,
            submit_error: nil,
            database: database
          )
          |> put_changesets(%{"database" => %{}, "replication_slot" => %{}})
          |> assign(:show_supabase_pooler_prompt, false)

        {:ok, socket}

      {:error, %NotFoundError{}} ->
        Logger.error("Database not found (id=#{id})")
        {:ok, push_navigate(socket, to: ~p"/databases")}
    end
  end

  defp fetch_or_build_database(socket, nil) do
    {:ok, %PostgresDatabase{account_id: current_account_id(socket), replication_slot: %PostgresReplicationSlot{}}}
  end

  defp fetch_or_build_database(socket, id) do
    with {:ok, database} <- Databases.get_db_for_account(current_account_id(socket), id) do
      {:ok, Repo.preload(database, :replication_slot)}
    end
  end

Many of these assigns are common across our forms:

  • is_edit?: Will determine which changeset we use and how we handle submits.
  • show_errors?: We don't want to show errors in the form until after the first submit.
  • submit_error: For some forms, you want to show a "global" error as opposed to an input-related one. When creating a database for example, we'll test if we can connect to it. The errors for that are global and won't appear under a specific input.
  • database: This is where we keep the database struct.

We always set account_id manually on structs vs via changesets (users should never be able to modify). Hence why we initialize PostgresDatabase with its account_id.

Under the hood, the database form is actually managing two entities, a PostgresDatabase and a PostgresReplicationSlot. So, put_changesets/2 manages two changesets. We just need to switch between changeset functions based on is_edit?:

  defp put_changesets(socket, params) do
    is_edit? = socket.assigns.is_edit?
    database = socket.assigns.database

    changeset =
      if is_edit? do
        PostgresDatabase.update_changeset(database, params["database"])
      else
        PostgresDatabase.create_changeset(database, params["database"])
      end

    replication_changeset =
      if is_edit? do
        PostgresReplicationSlot.update_changeset(database.replication_slot, params["replication_slot"])
      else
        PostgresReplicationSlot.create_changeset(%PostgresReplicationSlot{}, params["replication_slot"])
      end

    socket
    |> assign(:changeset, changeset)
    |> assign(:replication_changeset, replication_changeset)
  end

To communicate with the Svelte form component, we need to send it JSON. For updates, the form will need to initialize with the database's current values. We encode/decode structs in the LiveView with functions like encode_database/1:

  defp encode_database(%PostgresDatabase{} = database) do
    %{
      "id" => database.id,
      "name" => database.name || Name.generate(99),
      "database" => database.database,
      "hostname" => database.hostname,
      "port" => database.port || 5432,
      "username" => database.username,
      "password" => database.password,
      "ssl" => database.ssl || false,
      "publication_name" => database.replication_slot.publication_name || "sequin_pub",
      "slot_name" => database.replication_slot.slot_name || "sequin_slot"
    }
  end

We like this pattern over relying on the default Jason.Encoder for our structs. It's more explicit. And note that we're combining the PostgresDatabase and PostgresReplicationSlot structs here–Svelte doesn't need to know that they are separate entities on the backend!

This pattern also lets us define default values in the backend (e.g. || 5432 and || "sequin_pub"). We try to keep as much business logic as possible in Elixir. We want our Svelte components' only jobs to be the little UI details, like animations.

You can see all that background come together in our render/1:

  @parent_id "databases_form"
  @impl Phoenix.LiveView
  def render(assigns) do
    %{changeset: changeset, replication_changeset: replication_changeset} = assigns

    assigns =
      assigns
      |> assign(:encoded_database, encode_database(assigns.database))
      |> assign(:parent_id, @parent_id)
      |> assign(
        :form_errors,
        %{
          database: Error.errors_on(changeset),
          replication: Error.errors_on(replication_changeset)
        }
      )

    ~H"""
    <div id={@parent_id}>
      <.svelte
        name="databases/Form"
        ssr={false}
        props={
          %{
            database: @encoded_database,
            errors: if(@show_errors?, do: @form_errors, else: %{}),
            parent: @parent_id,
            submitError: @submit_error,
            isSupabasePooled: @is_supabase_pooled
          }
        }
      />
    </div>
    """
  end

One pattern we like to use is setting an id on the parent div, and then sending that down as the prop parent to the Svelte component. You'll see why in the next section when we're looking at the Svelte component.

For errors, we use errors_on to convert the changeset errors into a standard JSON format our Svelte component can use.

🤷‍♂️
We'd love to use SSR, but it just doesn't work well for LiveSvelte at the moment.

That covers data flow from the LiveView down to the Svelte component. Next we'll look at the Svelte component. You'll see data flow in the opposite direction, from the Svelte component back up to the LiveView.

The Svelte form component

Under assets/svelte/databases/Form.svelte, after a bunch of imports, we define our props:

  export let database: {
    id?: string;
    name: string;
    database: string;
    hostname: string;
    port: number;
    username: string;
    password: string;
    ssl: boolean;
    publication_name: string;
    slot_name: string;
  };
  export let errors: Record<string, any> = {};
  export let submitError: string | null = null;
  export let parent: string;
  export let live;
  export let isSupabasePooled: boolean = false;

In Svelte, export signals a prop. These are the attributes that are controlled by DatabasesLive.Form.

database is the encoded database. For creates, it will contain default values for the form. For updates, it will contain the current values for the database.

live is a special LiveSvelte prop. It's how the component communicates with the LiveView, which you'll see in play in a moment.

We then have the Svelte component create a new variable, form, which often looks like this:

  let form = { ...database };

So, the component initializes its form object with the incoming database. From now on, the database prop won't change. So, the Svelte component makes a new object that it owns, which it will manipulate as the user updates the form.

Most inputs in the form look just like this:

<div class="space-y-2">
  <Label for="hostname">Host</Label>
  <Input type="text" id="hostname" bind:value={form.hostname} />
  {#if databaseErrors.hostname}
    <p class="text-destructive text-sm">{databaseErrors.hostname}</p>
  {/if}
</div>

Pretty simple stuff. Bind specific attributes in form to their respective inputs. If there's an error to show below the input, show it.

To update the backend with form progress, we have this reactive line:

// We actually do something slightly different, more on that next
$: live.pushEvent("form_updated", { form });

Whenever form changes, Svelte will invoke this line and send the form to the backend. We'll see the LiveView handler for that in the next section.

For some forms, it's only necessary to send the backend data when the form is submitted. But often, it can be good to send changes live. As we'll see, the backend will recompute the changesets on every change. That means recomputing the form errors, which are passed to the Svelte component as props. The result is that errors will clear as users correct inputs, which is a common design pattern.

There's one big issue with using live.pushEvent everywhere though: it only works with LiveViews. But, sometimes we want Svelte components to communicate with LiveComponents. We didn't like the idea of having our Svelte components "know" if they were talking to a LiveView or LiveComponent. So, this is where we use the parent prop.

We define a helper function, pushEvent:

  function pushEvent(
    event: string,
    payload = {},
    callback: (reply?: any) => void = () => {}
  ) {
    live.pushEventTo(`#${parent}`, event, payload, callback);
  }

live.pushEventTo works with both LiveViews and LiveComponents. It needs a DOM element to send the event to. We pass in parent which is the ID of the parent div that rendered this Svelte component.

With that helper in place, we call pushEvent everywhere in the Svelte component, like so:

$: pushEvent("form_updated", { form });

One more thing before we jump back up to the LiveView: forms have a handleSubmit callback:

  <form on:submit={handleSubmit} class="space-y-6 max-w-3xl mx-auto mt-6">
    // ...
  </form>

handleSubmit for the databases form looks like this:

  function handleSubmit(event: Event) {
    event.preventDefault();
    validating = true;
    pushEvent("form_submitted", { form }, () => {
      validating = false;
    });
  }

This sends the form_submitted event to our LiveView. The validating variable is common in our forms: we set it to true before the submit. The key is that we're able to pass a callback function to pushEvent which is invoked when our LiveView has finished processing the form_submitted event. That lets us show a loading state on our buttons:

<Button type="submit" loading={validating} variant="default">
  {#if validating}
    Validating...
  {:else if isEdit}
    Update Database
  {:else}
    Connect Database
  {/if}
</Button>

As you can see, Svelte has little business logic. All about display.

LiveView event handlers

Before we look at the corresponding LiveView event handlers, let's check out decode_params. This is how we deserialize the event payloads coming from the frontend:

  defp decode_params(form) do
    %{
      "database" => %{
        "name" => form["name"],
        "hostname" => form["hostname"],
        "port" => form["port"],
        "database" => form["database"],
        "username" => form["username"],
        "password" => form["password"],
        "ssl" => form["ssl"]
      },
      "replication_slot" => %{
        "publication_name" => form["publication_name"],
        "slot_name" => form["slot_name"]
      }
    }
  end

Again, the encode/decode functions serve as our boundary layer. It's how we separate the shape of data that best fits the frontend/form from the shape of our Ecto models.

😎
This boundary layer was one of the big things we were missing when using just LiveView. Changesets encourage you to propagate your data shapes all the way to your frontend forms. While that would be fine for the databases form, there are others where this coupling is less desirable.

Here's what the form_updated handler looks like:

  @impl Phoenix.LiveView
  def handle_event("form_updated", %{"form" => form}, socket) do
    params = decode_params(form)
    socket = put_changesets(socket, params)

    show_supabase_pooler_prompt = detect_supabase_pooled(params["database"])
    socket = assign(socket, :show_supabase_pooler_prompt, show_supabase_pooler_prompt)

    {:noreply, socket}
  end

We saw the put_changesets function earlier. That's the one that will generate the form errors, which we pass down to Svelte. The Supabase pooler stuff is an enhancement to the form experience that we made specifically for Supabase (more on that later).

Here's the handler for form_submitted:

  @impl Phoenix.LiveView
  def handle_event("form_submitted", %{"form" => form}, socket) do
    params = decode_params(form)

    socket =
      socket
      |> put_changesets(params)
      |> assign(:show_errors?, true)

    with true <- socket.assigns.changeset.valid?,
         true <- socket.assigns.replication_changeset.valid? do
      case validate_and_create_or_update(socket, params) do
        {:ok, database} ->
          {:noreply, push_navigate(socket, to: ~p"/databases/#{database.id}")}

        {:error, %Ecto.Changeset{} = changeset} ->
          {:noreply, assign(socket, :changeset, changeset)}

        {:error, error} when is_error(error) ->
          {:noreply, assign(socket, :submit_error, Exception.message(error))}

        {:error, error} ->
          {:noreply, assign(socket, :submit_error, error_msg(error))}
      end
    else
      _ ->
        {:noreply, socket}
    end
  rescue
    error ->
      Logger.error("Crashed in databases/form.ex:handle_event/2: #{inspect(error)}")
      {:noreply, assign(socket, :submit_error, error_msg(error))}
  end

The database form_submitted handler has the most complicated error handling in our app. That's because there's a lot going on beneath the hood in validate_and_create_or_update. We run a battery of tests against the database connection to ensure it's sound. (We want to give the user super helpful error messages if we can't reach the database. Not just "Can't connect" but "can't connect because we couldn't resolve the hostname.")

If the submit is successful, Elixir routes the user to the databases show page.

Routing

One of the best parts of using LiveView is that it means all your routing happens in one place. You're not maintaining a set of routes for your API and another for your frontend application. (And Phoenix's router is the best.)

So, for example, if the user closes the database form, Svelte just tells LiveView about it:

<FullPageModal
  title="Connect Database"
  bind:open={dialogOpen}
  bind:showConfirmDialog
  on:close={() => pushEvent("form_closed")}
>

And then LiveView takes care of the routing:

  @impl Phoenix.LiveView
  def handle_event("form_closed", _params, socket) do
    socket =
      if socket.assigns.is_edit? do
        push_navigate(socket, to: ~p"/databases/#{socket.assigns.database.id}")
      else
        push_navigate(socket, to: ~p"/databases")
      end

    {:noreply, socket}
  end

Otherwise, we use data-phx-link/data-phx-link-state everywhere in Svelte to do navigation. Here's an example from our Sidenav component:

<a href="/logout" data-phx-link="redirect" data-phx-link-state="push">
  <DropdownMenu.Item class="cursor-pointer">
    <LogOut class="mr-2 h-4 w-4" />
    Log out
  </DropdownMenu.Item>
</a>

This pattern gives us "real" links, so users can right click to open in a new tab and all that good stuff.

Other events

Outside routing, my other favorite part about the LiveView/LiveSvelte paradigm is how Svelte components communicate with the server.

In a typical SPA, you're making an HTTP request, which means every request needs a ton of guards and error handling: can I reach the server? Is the user authenticated? Is the server running the same version of code that I am?

In the LiveView world, if you have a socket, you have the ear of the server. So, your calls to live.pushEvent and live.pushEventTo are straightforward. (If you don't have a socket, LiveView will display a global disconnected error to the user.)

And your LiveViews can respond to these event calls.

In our database form, we have an interesting requirement. If during create we detect that a user has given us a Supabase pooler connection, we prompt them to convert to a direct connection (necessary because Sequin uses the WAL). The backend determines this prompt should be shown, and sends down the boolean prop isSupabasePooled:

When the user clicks the button, we push this event to the backend:

 pushEvent("convert_supabase_connection", { form }, (reply) => {
   form = { ...form, ...reply.converted, ssl: true };
   showSupabasePoolerPrompt = false;
  }
});

The backend LiveView does the conversion and sends a reply:

  @impl Phoenix.LiveView
  def handle_event("convert_supabase_connection", %{"form" => form}, socket) do
    params = decode_params(form)
    converted_params = convert_supabase_connection(params["database"])

    socket = assign(socket, :show_supabase_pooler_prompt, false)

    {:reply, %{converted: converted_params}, socket}
  end

Note the :reply tuple to send a response to the frontend – neat! There's something so cool about a GenServer responding "directly" to a JavaScript component.

Converting a pooled connection to a direct connection is something that the Svelte component could do. But again, we like to keep Svelte's responsibilities focused. The more that's in Elixir, the more we can avoid duplicating business logic. And Elixir is way easier for us to unit test.

More to learn

The architecture of a stateful backend process paired with a stateful frontend process feels very powerful. LiveView makes this possible.

We're still early with the LiveView/LiveSvelte combo. And these tools are early. So lots is sure to change. We're still getting a feel on where to draw the boundary, and how much view templating code to have in Svelte vs LiveView.

But the team feels mega productive. We're able to turn ideas to reality super fast and with minimal friction/moving parts.