# `PhoenixKitCatalogue.Catalogue.BrowseState`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.19.1/lib/phoenix_kit_catalogue/catalogue/browse_state.ex#L1)

Pure browse-state reducer for paged, searchable, category-filtered item
browsing — the shared brain of `ItemSelectorModal` and any embedded
browse surface.

Holds no process and runs no queries. A caller feeds it commands, it
answers with what to fetch; the caller runs the query (however it likes —
synchronously in `handle_event/3` today, `start_async` tomorrow) and hands
the result back to `ingest/4`. That split is what makes every transition
testable without a database, and what lets two different LiveComponents
drive one implementation instead of drifting copies.

    {state, effect} = BrowseState.command(state, {:search, "screw"})
    # effect: {:fetch, query_opts, gen} | :noop

    case effect do
      {:fetch, opts, gen} ->
        items = Search.search_items(state.search, opts)
        total = Search.count_search_items(state.search, opts)
        BrowseState.ingest(state, gen, items, total)

      :noop ->
        state
    end

## Scope is a boundary, not a default

`init/1` fixes the host-supplied scope for the state's lifetime and every
`query_opts/1` re-derives from it, so no later command can widen it: a
category command narrows WITHIN `scope.category_uuids` (and is rejected
outside it), search composes with it, and nothing can un-set
`:catalogue_uuids` / `:only`. A crafted client event can therefore never
make the fetch layer return items the host did not allow.

## Generations

Every effectful command bumps `gen`, and `ingest/4` discards results
carrying a stale one. With synchronous fetches events serialize and the
guard never fires; it exists so moving to async fetches is a drop-in
rather than a redesign — a page 1 of "a" resolving after the state reset
to "ab" must not append the wrong results.

# `t`

```elixir
@type t() :: %PhoenixKitCatalogue.Catalogue.BrowseState{
  category_uuid: term(),
  exhausted?: term(),
  gen: term(),
  items: term(),
  known_uuids: term(),
  loading?: term(),
  page: term(),
  per_page: term(),
  scope: term(),
  search: term(),
  total: term()
}
```

# `command`

Applies a command. Returns `{state, {:fetch, query_opts, gen}}` when the
caller should run a fetch, `{state, :noop}` otherwise.

Commands:

  * `:reset` — first load / clear everything back to the scope.
  * `{:search, q}` — replace the search string (no-op when unchanged).
  * `{:set_category, uuid | nil}` — narrow to one category (nil = all
    within scope). Rejected with `:noop` when the uuid falls outside
    `scope.category_uuids` — scope only ever narrows.
  * `:load_more` — next page. No-op while loading or exhausted.

# `ingest`

Folds a fetched page into the state. A stale `gen` is discarded whole —
the state that requested it no longer exists.

Appends are de-duplicated by uuid: offset paging over a live catalogue can
re-serve a row when the sort shifts between fetches, and a duplicate card
(same DOM id twice) is worse than a briefly missing one.

# `init`

Builds the initial state. `opts`:

  * `:scope` — map with any of `:catalogue_uuids`, `:category_uuids`,
    `:only`, `:statuses`, `:include_descendants`. Fixed for the state's
    lifetime. Unknown keys raise `ArgumentError`.
  * `:per_page` — page size (default 24).

# `query_opts`

The `Search.search_items/2` opts for the current state — always derived
from the immutable scope, never from anything a client event set directly.

---

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