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
endParameter 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_querybut 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,:atomor:boolean.:in— allowed values. Anything else falls back to the default. Required forcast: :atom: the incoming string is matched against this list, so no atom is ever created from user input.:min/:max— bounds forcast: :integer. Out-of-range falls back to the default.:maxdefaults to 1_000_000 for integers: an unbounded page number out of the URL overflows PostgreSQL'sbigintonce it reachesOFFSET, 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) runshandle_url_state/2on the disconnected render as well, so the first paint already carries the list. This is what amount/3that loads its data already does, which is why it is the default: adopting the module does not change what the user sees.:skipruns 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:skipthe 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:pagewhen the spec declares it;falsedisables 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_patchfrom a root LiveView reachessync_handle_params_with_live_redirect/5, which invokesview.handle_params/3unconditionally — the 4-arityUtils.call_handle_params!defaultsexported?totrue. Sohandle_params/3must be exported.- On an embedded mount (
socket.root_pid != self()),maybe_call_mount_handle_params/4seesany? = callbacks? or exported?and takes the branch that raises throughRoute.live_link_info!. Merely exportinghandle_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/3applies the state itself and pushes the new query to the client, which rewrites the address bar (pushState, orreplaceStatewhen you passreplace: true). There is no round trip.- Back and Forward arrive as a
popstatereport from the hook, decoded the same way a patch would be. - The LiveView keeps loading its list in
mount/3. There is nohandle_paramsto hang the first call on, sohandle_url_state/2serves changes only. Declared params are still assigned beforemount/3runs, so a router-mounted LiveView loads the right thing immediately. - On an embedded mount, params arrive as
:not_mounted_at_router, somount/3sees the defaults and the hook corrects it on connect — one extra load, and only when the URL actually carried state. url_state_path/2and<.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/2bare. Adding@implhere turnsmount/3,handle_event/3and friends into warnings, whichmix precommitcompiles as errors. - Annotates its callbacks (Andi's LiveViews) — annotate
handle_url_state/2too, 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
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
Callbacks
@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
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.
Public so a LiveView that does its own thing with handle_params/3 can still
share the exact codec.
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.
@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.
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.
@spec reset_url_state(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Resets every declared parameter to its default — the "clear all filters" action. Unknown query keys are preserved.
@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>
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:modethe LiveView declared. Defaults to:patch.id(:string) - unique when nested. Defaults to"phoenix-kit-url-state".