PhoenixKitWeb.Live.UrlState behaviour (phoenix_kit v1.7.236)

Copy Markdown View Source

URL-backed list state for LiveView index pages — search, filters, sort, page.

A list screen's state belongs in the address bar: the result is then a real URL that can be pasted to a colleague, bookmarked, and reproduced by a reload, and the browser Back button returns to the previous query instead of leaving the page.

Declare the state, handle one callback, and push changes:

defmodule MyAppWeb.UsersLive do
  use MyAppWeb, :live_view

  use PhoenixKitWeb.Live.UrlState,
    params: [
      search_query: [default: "", url_key: "q", alias: "search"],
      filter_role:  [default: "all", url_key: "role"],
      sort_by:      [default: "inserted_at", in: ~w(inserted_at email)],
      sort_dir:     [default: :desc, cast: :atom, in: [:asc, :desc]],
      page:         [default: 1, cast: :integer, min: 1]
    ]

  def handle_url_state(state, socket) do
    assign(socket, :users, Users.list(state))
  end

  def handle_event("search", %{"search" => q}, socket) do
    {:noreply, push_url_state(socket, [search_query: q], replace: true)}
  end
end

Parameter spec

Each entry is assign_name: opts. The key is the socket assign the value lands in, so adopting an existing LiveView is a matter of listing the assigns it already has — its template does not change.

  • :default — required. The value used when the key is absent or invalid. Values equal to the default are omitted from the query string, so an unfiltered list is /admin/users, not /admin/users?q=&role=all&page=1.
  • :url_key — the query-string key. Defaults to the assign name. Use it when the assign is :search_query but the URL should read ?q=.
  • :alias — an additional key accepted when reading, never written. Lets a screen that already published ?search= links converge on ?q= without breaking them. Accepts a string or a list of strings.
  • :cast:string (default), :integer, :atom or :boolean.
  • :in — allowed values. Anything else falls back to the default. Required for cast: :atom: the incoming string is matched against this list, so no atom is ever created from user input.
  • :min / :max — bounds for cast: :integer. Out-of-range falls back to the default. :max defaults to 1_000_000 for integers: an unbounded page number out of the URL overflows PostgreSQL's bigint once it reaches OFFSET, turning a crafted link into a 500.

Unknown query keys are preserved across patches, so an unrelated param is not dropped when a filter changes — the media selector is opened with ?return_to=…&mode=single, and both survive a search.

Options

  • :params — the spec above. Required.
  • :dead_render:call (default) runs handle_url_state/2 on the disconnected render as well, so the first paint already carries the list. This is what a mount/3 that loads its data already does, which is why it is the default: adopting the module does not change what the user sees. :skip runs the callback only once the socket is connected, halving the queries per page load — worth it on a heavy list, wrong on a page that must serve content to crawlers. ⚠ With :skip the callback has not run when the disconnected render happens, so any assign the callback sets does not exist yet: the template must tolerate that (@users || []), or mount must seed a placeholder. Otherwise the dead render raises rather than merely painting empty.
  • :page_param — the assign reset whenever another parameter changes. Defaults to :page when the spec declares it; false disables the reset.

Writing state

push_url_state/3 merges the changes, resets the page parameter unless the page itself was what changed, drops defaults, and patches the path the LiveView is currently on — captured from the live uri, not rebuilt from a literal. A screen reachable at more than one route (a sub-tab such as /orders/:id/edit/files) therefore stays where it is, and the locale segment survives.

Pass replace: true for continuous input. A debounced search box otherwise writes one history entry per pause in typing, and Back walks the query backwards a few characters at a time instead of leaving the page. Discrete actions — picking a filter, sorting, changing page — should push a real entry.

For links rather than events (<.pagination>, <.link patch=…>), build the target with url_state_path/2.

:patch is router-only; embeddable LiveViews need :history

The default, mode: :patch, makes a LiveView impossible to embed with live_render/3. The two requirements are mutually exclusive in Phoenix LiveView itself:

  • push_patch from a root LiveView reaches sync_handle_params_with_live_redirect/5, which invokes view.handle_params/3 unconditionally — the 4-arity Utils.call_handle_params! defaults exported? to true. So handle_params/3 must be exported.
  • On an embedded mount (socket.root_pid != self()), maybe_call_mount_handle_params/4 sees any? = callbacks? or exported? and takes the branch that raises through Route.live_link_info!. Merely exporting handle_params/3 — whatever its body — makes a LiveView un-embeddable.

One requires exactly what the other forbids. mode: :history sidesteps both by never touching handle_params at all: the browser owns the URL, and the LiveView talks to it through a JS hook.

use PhoenixKitWeb.Live.UrlState,
  mode: :history,
  params: [search: [default: "", url_key: "q"]]

The template must render the hook's element once:

<.url_state_sync mode={:history} />

What changes in :history mode:

  • push_url_state/3 applies the state itself and pushes the new query to the client, which rewrites the address bar (pushState, or replaceState when you pass replace: true). There is no round trip.
  • Back and Forward arrive as a popstate report from the hook, decoded the same way a patch would be.
  • The LiveView keeps loading its list in mount/3. There is no handle_params to hang the first call on, so handle_url_state/2 serves changes only. Declared params are still assigned before mount/3 runs, so a router-mounted LiveView loads the right thing immediately.
  • On an embedded mount, params arrive as :not_mounted_at_router, so mount/3 sees the defaults and the hook corrects it on connect — one extra load, and only when the URL actually carried state.
  • url_state_path/2 and <.link patch=…> do not apply — there is no router to patch against. Drive everything through events.
  • Only the query is exchanged; the path stays client-side, because an embedded LiveView does not know what page it is on. One synced LiveView per page — two would fight over the same query keys.

In :patch mode, a LiveView that already defines its own handle_params/3 keeps it and the state hook composes alongside — both run. Only one without it gets the stub that push_patch requires; in :history mode the stub is deliberately never injected.

Setting a declared param outside an event

Prefer push_url_state/3 so the address bar changes with the state. But a plain assign/3 on a declared param is safe: the next patch reads its merge base back from the assigns, so the freshest value wins and the URL catches up rather than resurrecting what was superseded.

This matters for screens that adjust their own state as a side effect — a list re-picking its sort column after the current one is hidden, say. Before this was handled, such a reset left the old column in the URL, the next search re-applied it, and a reload sorted by a column that was no longer visible.

@impl is all-or-nothing

Elixir demands @impl on every callback of a module that uses it on any one of them, so match whatever the LiveView already does:

  • Annotates nothing (core's own LiveViews) — leave handle_url_state/2 bare. Adding @impl here turns mount/3, handle_event/3 and friends into warnings, which mix precommit compiles as errors.
  • Annotates its callbacks (Andi's LiveViews) — annotate handle_url_state/2 too, and define an explicit @impl true def handle_params(_params, _uri, socket), do: {:noreply, socket}. The stub injected below carries no @impl, so letting it be injected into an annotating module is itself a warning.

Summary

Types

Decoded state: assign name => value

Callbacks

Invoked with the decoded state whenever it changes, and once after mount.

Functions

Builds path?query from a state map, or bare path when nothing differs from the defaults.

Decodes LiveView params into the state map, applying defaults.

Encodes the state into a query map, omitting every value equal to its default.

Merges changes into the current state and patches the URL.

Whether a navigation should re-run handle_url_state/2.

Resets every declared parameter to its default — the "clear all filters" action. Unknown query keys are preserved.

The path this LiveView would patch to for changes — for <.link patch=…> and <.pagination>, which navigate by href rather than by event.

Renders the element mode: :history needs, and nothing in :patch mode.

Types

state()

@type state() :: %{required(atom()) => term()}

Decoded state: assign name => value

Callbacks

handle_url_state(state, t)

@callback handle_url_state(state(), Phoenix.LiveView.Socket.t()) ::
  Phoenix.LiveView.Socket.t()

Invoked with the decoded state whenever it changes, and once after mount.

Returns the socket, typically with the list re-queried. Runs after the LiveView's own mount/3, so assigns set there are available.

Functions

build_path(path, state, cfg, extra \\ %{})

@spec build_path(String.t(), state(), map(), map()) :: String.t()

Builds path?query from a state map, or bare path when nothing differs from the defaults.

decode(params, cfg)

@spec decode(map(), map()) :: state()

Decodes LiveView params into the state map, applying defaults.

Public so a LiveView that does its own thing with handle_params/3 can still share the exact codec.

encode(state, cfg, extra \\ %{})

@spec encode(state(), map(), map()) :: %{required(String.t()) => String.t()}

Encodes the state into a query map, omitting every value equal to its default.

extra carries query keys the spec does not know about so that an unrelated param survives a filter change.

push_url_state(socket, changes, opts \\ [])

@spec push_url_state(Phoenix.LiveView.Socket.t(), keyword() | map(), keyword()) ::
  Phoenix.LiveView.Socket.t()

Merges changes into the current state and patches the URL.

Resets the page parameter unless the page itself changed. Pass replace: true for continuous input so a debounced search box leaves one history entry instead of one per keystroke pause.

reload?(loaded?, new_state, previous_state)

@spec reload?(boolean() | nil, state(), state() | nil) :: boolean()

Whether a navigation should re-run handle_url_state/2.

The hook fires on every navigation in the LiveView, not only the ones this module caused. A patch touching an unrelated query key — the media selector's ?return_to=…, a host LiveView's own patch — must not make the list re-run its queries, so the callback runs only when the declared state actually differs, or when it has never run at all.

Public because it is the one branch that decides whether a shared link, a Back press or a filter change reloads; it is worth pinning in a test without a router.

reset_url_state(socket)

Resets every declared parameter to its default — the "clear all filters" action. Unknown query keys are preserved.

url_state_path(socket_or_assigns, changes)

@spec url_state_path(Phoenix.LiveView.Socket.t() | map(), keyword() | map()) ::
  String.t()

The path this LiveView would patch to for changes — for <.link patch=…> and <.pagination>, which navigate by href rather than by event.

Takes a socket, or — from inside a template, where @socket carries no assigns — the template's own assigns:

<.link patch={url_state_path(assigns, page: page)}>{page}</.link>

url_state_sync(assigns)

Renders the element mode: :history needs, and nothing in :patch mode.

The browser owns the URL there, so something has to carry the JS hook that reports the query on connect, rewrites the address bar on a change, and reports Back and Forward. Put it anywhere inside the LiveView's own markup:

<.url_state_sync mode={:history} />

Attributes

  • mode (:atom) - the :mode the LiveView declared. Defaults to :patch.
  • id (:string) - unique when nested. Defaults to "phoenix-kit-url-state".