# `PhoenixKitWeb.Live.UrlState`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.13.7/lib/phoenix_kit_web/live/url_state.ex#L1)

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.

# `state`

```elixir
@type state() :: %{required(atom()) =&gt; term()}
```

Decoded state: assign name => value

# `handle_url_state`

```elixir
@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.

# `build_path`

```elixir
@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`

```elixir
@spec decode(map() | :not_mounted_at_router, 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`

```elixir
@spec encode(state(), map(), map()) :: %{required(String.t()) =&gt; 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.

# `extras_from_uri`

```elixir
@spec extras_from_uri(String.t() | URI.t(), map()) :: %{
  required(String.t()) =&gt; String.t()
}
```

Unknown query keys from a URI, excluding declared UrlState keys.

Reads the URI's **query string only** — never the path — so a router
segment such as `/:uuid` is not treated as an extra to re-encode into
the next patch. Public so a LiveView that owns `handle_params/3` can
share the same rule, and so the contract is cheap to pin in a test.

# `push_url_state`

```elixir
@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?`

```elixir
@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`

```elixir
@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.

# `url_state_path`

```elixir
@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`

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"`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
