Persistent forms with your URL on LiveView

A mouse cursor near a search form, in a whimsical cartoon style.
Image by Annie Ruygt

In this article we talk about syncing LiveView state with URL. Fly.io is a great place to run your Phoenix LiveView applications! Check out how to get started!

Problem

Say you have a simple posts search form LiveView like this one:

A form where you can search by post name and/or author

Whenever users change the title or author on the search form you perform the search just fine but whenever you hit the refresh button your filters are emptied, poof. We did not persist those in any way, that’s why.

When you refresh the page all your form input values are gone

How can we persist our filters so that a page refresh would not make us have to do it all over again?

Solution

We will use a simple trick to sync your URL query string with you LiveView filters using nothing but push_patch/2 and handle_params/3.

Run your forms on Fly.io

You can host your LiveView form apps here on Fly.io and get free SSL so users can see the lock icon on their browsers when they also see their URL change too.

Try Fly for free

The initial state

It’s very likely you used Phoenix’s generators so your code must look almost like this on your index.ex:

defmodule FormUrlRecipeWeb.PostLive.Index do
  use FormUrlRecipeWeb, :live_view

  alias FormUrlRecipe.Blog
  alias FormUrlRecipe.Blog.Post

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, posts: [], authors: [])}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  # Other actions omitted here, lets focus on the list

  defp apply_action(socket, :index, _params) do
    changeset = Blog.change_post(%Post{})

    socket
    |> assign(:page_title, "Listing Posts")
    |> assign(:post, nil)
    |> assign(:posts, Blog.search_posts(%{}))
    |> assign(:authors, Blog.list_authors())
    |> assign(:changeset, changeset)
  end

  @impl true
  def handle_event("search_posts", %{"post" => attrs}, socket) do
    changeset = Blog.change_post(%Post{}, attrs)

    {:noreply,
      socket
      |> assign(:changeset, changeset)
      |> assign(:posts, Blog.search_posts(attrs))
    }
  end

  # ...
end

As you can see there’s two places who search for posts using Blog.search_posts. The first is inside apply_action/3 which runs from handle_params/3, those happen when you open the page, and the other one is inside the handle_event("search_posts", ...) which is triggered from our form component.

We can simplify this code and centralize where posts are loaded from on apply_action/3 by simply making our handle_event/3 just send you back to the same LiveView through push_patch/3 like this:

@impl true
def handle_event("search_posts", %{"post" => attrs}, socket) do
  {:noreply,
    socket
    |> push_patch(to: Routes.post_index_path(socket, :index, attrs))
  }
end

Assume we change the author to Michael, you will be sent to /posts?author=Michael&title=. Now we just need to start parsing the URL into a attributes we can fill our changeset and our search function:

defp apply_action(socket, :index, params) do
  # Changed these two lines below
  attrs = Map.take(params, ["title", "author"])
  changeset = Blog.change_post(%Post{}, attrs)

  socket
  |> assign(:page_title, "Listing Posts")
  |> assign(:post, nil)
  # Changed this line below
  |> assign(:posts, Blog.search_posts(attrs))
  |> assign(:authors, Blog.list_authors())
  |> assign(:changeset, changeset)
end

And that’s it! We now sync URL with our forms plus we refactored our LiveView to deduplicate the search being run. Don’t believe me? Here’s the commit URL.

Notes

It’s worth mentioning Phoenix will understand any empty field as empty string so your params might look like %{"title" => "", "author" => "Lubien"}. For my search functionality to work the way I wanted I’ve filtered any nil or "" inside the Blog.search_posts function.