Lists & URL state
A list page that keeps its query in the page URL — /articles?q=react&sort=title&page=2 —
survives a reload, can be shared, and works with the back button. The string work behind it is the
same on every page: read the query into typed values, fall back when someone hand-edits it, write
it back without defaults, return to page 1 when a filter changes. The lists functions do that work
once.
They are pure transformations, like the rest of the core. They read and write URLs; they never
navigate, subscribe to a router or fetch. That is left to your framework — or to an adapter such
as @qubeejs/react.
URL or memory?
Section titled “URL or memory?”Keep a list’s state in the page URL when the list is the page: an article index, search results, an admin table. Those are the pages people reload, share and come back to with the back button, and a server can render them from the URL it receives, with no client state to wait for.
Keep it in memory when the list lives inside something else — a dialog, a picker, an autocomplete, a second table on the same screen — or when the app has no routing. Written to the URL, its state would add history entries nobody goes back to, and its keys could clash with the page’s own.
Both run on the same builder; a list only declares what drives it. In memory, you have two options:
- The same list, with its query kept outside the address bar. The functions on this page read
the query from a value and write it back as a string; they never touch the address bar, so the
query can just as well live in a component’s state.
@qubeejs/react’sMemoryAdapterruns a list this way. - The builder alone, through
createQubee(), when the state is not worth declaring as params: calladdFilter(),nextPage()and the rest yourself, and subscribe to its store.
Pick one place per list. If the URL and a store both hold the same state, every change has to be synced in both directions.
Declaring a list
Section titled “Declaring a list”import { defineList, enumParam, integerParam, SortEnum, sortParam, STRAPI_DRIVER, stringParam,} from '@qubeejs/core';
export const articleList = defineList({ apply: (builder, { q, sort, status }) => { builder.setLimit(20); sort.forEach(({ field, order }) => builder.addSort(field, order));
if (q) { builder.addFilter('title', q); }
if (status) { builder.addFilter('status', status); } }, params: { page: integerParam('page', { default: 1, min: 1 }), q: stringParam('q'), status: enumParam('status', ['draft', 'published']), sort: sortParam('sort', { default: [{ field: 'publishedAt', order: SortEnum.DESC }], fields: ['publishedAt', 'title'], }), }, qubee: { baseUrl: 'https://example.com/api', driver: STRAPI_DRIVER }, resource: 'articles',});paramsmaps each name in list state to the page-URL key it lives in. Keys appear in links in the order you declare them.pageis required, and must hold a number with a default. The list returns to it when anything else changes, and applies it last when it builds a request.applyturns state into builder calls. It runs on a fresh builder whose resource is set. A list whose request needs more than the URL takes a third argument; see Lists that need more than the URL.qubeeis whatcreateQubee()takes: the driver, a base URL, key overrides.resourceis the default resource, whichapplymay replace withsetResource().
Declare each list once, as a module-level constant, and import it wherever you need it. It
holds functions, so it cannot travel as serialised data — as a prop from a React Server Component,
for instance. Two params with the same key, or an empty key, throw DuplicateListParamError where
the list is declared.
The state type comes from the definition:
import type { ListState } from '@qubeejs/core';
type ArticleListState = ListState<typeof articleList>;// {// readonly page: number;// readonly q: string | undefined;// readonly status: 'draft' | 'published' | undefined;// readonly sort: readonly Sort[];// }A param with a default always holds a value; one without holds undefined when the URL has none.
Params
Section titled “Params”| Param | Holds | Reads | Writes |
|---|---|---|---|
integerParam(key, { default?, min?, max? }) |
number |
digits with an optional -, within min–max |
'3' |
stringParam(key, { default?, trim? }) |
string |
any non-empty value | the value; nothing for '' |
enumParam(key, values, { default? }) |
a member of values |
one of values — a string enum or a tuple |
the value |
booleanParam(key, { default? }) |
boolean |
true / 1, false / 0 |
'true' / 'false' |
listParam(key, { values?, default?, separator? }) |
readonly T[] |
every value, split on ,; unknown items dropped |
one value, joined with , |
sortParam(key, { fields, default? }) |
readonly Sort[] |
-publishedAt,title — - is descending |
the same |
Every param falls back to its default when the URL holds nothing it can read — including a
single-value param that finds its key twice (?page=1&page=2). listParam and sortParam default
to [].
Because clearing a param means going back to its default, a multi-select whose default is not empty
cannot be cleared through a link: buildListHref(list, location, { status: [] }) leaves the key out,
and that reads back as the default. Give a listParam or sortParam an empty default if users must
be able to clear it.
stringParam keeps surrounding spaces unless you pass trim: true. A search box whose value is read
back from list state would otherwise lose the space its user has just typed; trim on the server, or
in apply.
sortParam can rename fields for the URL. State always holds the API’s field names:
sortParam('sort', { fields: { newest: 'publishedAt', title: 'title' } });// ?sort=-newest → [{ field: 'publishedAt', order: SortEnum.DESC }]Writing your own
Section titled “Writing your own”A param is any object of the ListParam<T> shape, so one can be backed by zod, a date library,
anything:
import type { ListParam } from '@qubeejs/core';
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export const sinceParam: ListParam<string | undefined> = { default: undefined, key: 'since', parse: (values) => (values.length === 1 && ISO_DATE.test(values[0]) ? values[0] : undefined), serialize: (value) => (value === undefined ? [] : [value]),};parse receives every value the URL holds for the key, and is never called with none. Returning
undefined — or throwing — falls back to the default. serialize returns [] to leave the key out.
Reading the URL
Section titled “Reading the URL”import { readListState } from '@qubeejs/core';
readListState(articleList, '?q=react&sort=title&page=2');// { page: 2, q: 'react', status: undefined, sort: [{ field: 'title', order: SortEnum.ASC }] }
readListState(articleList, '?page=banana&status=archived');// { page: 1, q: undefined, status: undefined, sort: [{ field: 'publishedAt', order: SortEnum.DESC }] }It never throws. A missing, unreadable or repeated value takes the param’s default, and keys the list does not own are ignored.
It takes the query in whatever shape your router hands over — a URLSearchParams (React Router,
Next’s useSearchParams()), a query string, or the searchParams record a Next page receives:
// searchParams: { q: 'react', tag: ['a', 'b'] } — `tag` is not one of articleList's paramsreadListState(articleList, await searchParams);// { page: 1, q: 'react', status: undefined, sort: [{ field: 'publishedAt', order: SortEnum.DESC }] }toSearchParams() performs that normalisation on its own, when you need a URLSearchParams.
Building links
Section titled “Building links”buildListHref() writes state back into a link, changing only what you ask:
import { buildListHref } from '@qubeejs/core';
const location = { pathname: '/articles', search: '?q=react&page=3' };
buildListHref(articleList, location, { page: 4 }); // '/articles?page=4&q=react'buildListHref(articleList, location, { status: 'draft' }); // '/articles?q=react&status=draft'buildListHref(articleList, location, { q: undefined }); // '/articles'- Defaults stay out of the URL, so the default state is the bare path.
- A change to anything but the page returns to page 1 — page 3 of one search means nothing in
another. Setting a param to the value it already holds keeps the page, and naming
pageyourself always wins. undefinedmeans “back to the default”, which is how you clear a filter that has none — the same goes for[]on a list or sort param. A param whose default is not empty resets to that default instead.- Keys the list does not own are kept — a drawer’s
?panel=editsurvives paging — ahead of the list’s own, which follow in declaration order. - An unreadable value in the current URL is dropped, and so is the hash.
Commas are left unescaped, so sorts stay readable: ?sort=-publishedAt,title. Never compare raw
query strings. toSearchParams(a).toString() against toSearchParams(b).toString() evens out
encoding (, against %2C, + against %20) but not key order: ?q=react&page=3 and
?page=3&q=react still differ. To ask whether the URL already shows a state — an active link, a
“did it change?” check — compare buildListHref(list, location, changes) with
buildListHref(list, location), which is the current URL in canonical form, or compare the states
readListState() returns:
buildListHref(articleList, location); // '/articles?page=3&q=react'buildListHref(articleList, location, { page: 3 }) === buildListHref(articleList, location); // true — already thereBecause the function is pure, a server can render every pagination link and filter chip as a plain
<a href>.
Building the request
Section titled “Building the request”buildListRequest() turns state into everything needed to fetch the page:
import { buildListRequest, readListState } from '@qubeejs/core';
const request = buildListRequest(articleList, readListState(articleList, '?page=2'));
request.uri; // 'https://example.com/api/articles?sort[0]=publishedAt:desc&pagination[page]=2&pagination[pageSize]=20'request.headers; // null — or PostgREST's Range, in RANGE mode
const response = await fetch(request.uri, { headers: request.headers ?? {} });const page = request.paginate<Article>(await response.json(), response.headers);Each call builds on a fresh createQubee() instance: it sets the resource, runs apply, and applies
the page last. addFilter(), addSort(), setLimit() and the rest reset the page to 1, so a
page set before them would be lost; the list does the ordering for you.
paginate parses with the paginator of the same instance; the headers matter for drivers that page
over them, such as PostgREST’s Content-Range. [request.uri, request.headers] is a
complete cache key — TanStack Query’s queryKey, SWR’s key:
useQuery({ queryFn: async () => { const response = await fetch(request.uri, { headers: request.headers ?? {} });
return request.paginate<Article>(await response.json(), response.headers).toPlain(); }, queryKey: ['articles', request.uri, request.headers],});Errors from apply — a filter your driver cannot express, say — are programmer errors and
propagate. So does a value a validating builder method refuses: give min and max to any param
that apply passes to one such as setLimit(), since a hand-edited value outside the builder’s
range would throw.
Lists that need more than the URL
Section titled “Lists that need more than the URL”Some lists depend on something the query string does not hold. /projects/42/tasks?status=open
shows the tasks of project 42, but 42 lives in the path. A list declares what its request needs
besides URL state as its input: apply receives it as a third argument, and
buildListRequest() takes it as a third argument too.
import { buildListRequest, defineList, enumParam, integerParam, readListState, STRAPI_DRIVER,} from '@qubeejs/core';
export const taskList = defineList({ apply: (builder, { status }, { projectId }: { projectId: string }) => { builder.addFilter('project', projectId);
if (status) { builder.addFilter('status', status); } }, params: { page: integerParam('page', { default: 1, min: 1 }), status: enumParam('status', ['open', 'done']), }, qubee: { driver: STRAPI_DRIVER }, resource: 'tasks',});
// projectId and search are what your router gives you for /projects/42/tasks?status=openconst request = buildListRequest(taskList, readListState(taskList, search), { projectId });
request.uri; // '/tasks?filters[project][$eq]=42&filters[status][$eq]=open&pagination[page]=1&pagination[pageSize]=15'- Annotate
apply’s third parameter to declare the input: its type becomes the list’s input. Without the annotation, the input is typedundefined, so using it inapply, or passing one tobuildListRequest(), fails to compile. - A declared input is required. Leaving it out is a compile error, and so is passing one to a list that declares none.
- Prefer an object such as
{ projectId }: adding a value later then breaks no call site. A bare value (projectId: string) works too.nullandundefinedare refused, so optional parts go inside the object, as in{ tenantId?: string }, passed as{}when empty. - The input never reaches the URL.
readListState()andbuildListHref()don’t take it, and links carry only params.[request.uri, request.headers]stays a complete cache key, because the input only reaches the request through the builder.
Lookups and request-scoped data
Section titled “Lookups and request-scoped data”The core does no I/O, so anything asynchronous is resolved before the call. When the URL holds a slug and the API filters by id, look the id up first:
const projectId = await findProjectId(slug); // your own lookupconst request = buildListRequest(taskList, readListState(taskList, search), { projectId });Session data works the same way: a tenant, or the current user for “my items”, goes into the input, not into a param.
Nested resources
Section titled “Nested resources”resource is the default, and apply may replace it. For an API that nests tasks under their
project, set the resource from the input:
apply: (builder, { status }, { projectId }: { projectId: string }) => { builder.setResource(`projects/${encodeURIComponent(projectId)}/tasks`);
if (status) { builder.addFilter('status', status); }},// '/projects/42/tasks?filters[status][$eq]=open&pagination[page]=1&pagination[pageSize]=15'The resource is not encoded, so encode every path segment that comes from data, as above. The
page is still applied after apply returns, so replacing the resource does not lose it.
Generic code
Section titled “Generic code”A list with an input is still a ListDefinition<ListParams>, so generic code, such as an adapter’s
hook or a helper that takes any list, accepts it. Through that type, though, the requirement is
erased: buildListRequest(list, state) compiles without the input, apply receives undefined,
and a third argument is refused. The same holds through an alias of it, such as
type LooseList = ListDefinition<ListParams>: the input is read from apply’s parameters, so how a
list’s type is written does not change it. Generic code carries the input itself, typed with
ListInput<TList>, and forwards it by holding the list twice, loose and widened:
import type { ListDefinition, ListInput, ListParams, ListRequest, SearchParamsInput,} from '@qubeejs/core';
function buildRequestFor<TList extends ListDefinition<ListParams>>( list: TList, search: SearchParamsInput, ...args: [ListInput<TList>] extends [never] ? [] : [input: ListInput<TList>]): ListRequest { const loose: ListDefinition<ListParams> = list; const wide: ListDefinition<ListParams, NonNullable<unknown>> = list; const [input] = args; const state = readListState(loose, search);
return input === undefined ? buildListRequest(loose, state) : buildListRequest(wide, state, input);}ListInput<typeof list> is never for a list that declares no input, and
[ListInput<TList>] extends [never] is how generic code tells the two apart. Both calls go through
a copy: on the generic TList, readListState() returns the loose state,
ParamsState<ListParams>, which buildListRequest() takes only with a list that is not generic.
Callers stay checked: buildRequestFor(taskList, search) is a compile error.
