Skip to content
Guide/Lists & URL state

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.

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’s MemoryAdapter runs a list this way.
  • The builder alone, through createQubee(), when the state is not worth declaring as params: call addFilter(), 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.

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',
});
  • params maps each name in list state to the page-URL key it lives in. Keys appear in links in the order you declare them.
  • page is 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.
  • apply turns 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.
  • qubee is what createQubee() takes: the driver, a base URL, key overrides.
  • resource is the default resource, which apply may replace with setResource().

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.

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 }]

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.

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 params
readListState(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.

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 page yourself always wins.
  • undefined means “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=edit survives 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 there

Because the function is pure, a server can render every pagination link and filter chip as a plain <a href>.

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.

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=open
const 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 typed undefined, so using it in apply, or passing one to buildListRequest(), 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. null and undefined are refused, so optional parts go inside the object, as in { tenantId?: string }, passed as {} when empty.
  • The input never reaches the URL. readListState() and buildListHref() 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.

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 lookup
const 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.

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.

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.