The list pipeline
A real list screen wants several things at once. Rows that stay fresh. Related records beside them. Derived values. A filter, a search box, a sort order. One composable owning all of that would hide which part did what.
useList stacks the concerns instead. Each one is its own layer, and each layer takes the previous layer's state as its parent. What your template renders is the last layer's view of rows the first layer owns.
That structure explains behaviour that is otherwise puzzling. After this page you should be able to say:
- which layer changed the rows you are looking at
- why
contacts.state.objectscan hold fewer rows than the server returned - which orderings among the layers matter, and which are only convention
This page stays on how the layers compose, and on what holds true across the chain as rows arrive, change, and drop out of view. It does not walk through configuring any single layer, which the how-to guides at the end cover. The examples use a contact list, with contactId as the primary key field.
Use useList when a plain instance is not enough. useListInstance alone owns rows and their identity, and nothing more. useList wraps it with everything a real list screen tends to need: refetching when inputs change, live updates, related and calculated values, filtering, text search, and sorting. Every layer exists in the chain, but each capability is optional. A list with no search rules and no sort rules still works, and those layers pass their rows through untouched.
The returned manager
The examples name the value returned by useList contacts:
const contacts = useList({ props, handlers });The manager keeps reactive values under contacts.state and exposes actions directly on contacts:
const contactId = "42";
contacts.state.pkKey; // The field that identifies each row
contacts.state.objects[contactId]; // A row accessed by primary key
contacts.state.order; // Primary keys in presentation order
contacts.state.objectsInOrder; // Rows in presentation order
contacts.state.relatedObjects[contactId].company;
contacts.state.calculatedObjects[contactId].displayLabel;
contacts.state.loading;
contacts.state.error;
contacts.state.errored;
contacts.list();
contacts.bulkDelete({ pks: [contactId] });The final contacts.state is flat rather than nested by pipeline layer. It contains the instance fields, subscription status, enrichment maps, and final filtered, searched, and sorted views together. The manager preserves the individual layers under contacts.managed for advanced use.
A transformation trace
Picture a contact list screen. props.params names which contacts to fetch. The subscription layer sees props.intendToList turn true and calls your list handler. The rows land in the instance, each keyed by its contactId. That primary key is how every later layer refers to a row.
Take one contact, Ada. Ada arrives in the instance as a row. The related layer reads Ada's company foreign key and resolves it against a collection of companies you already hold. The resolved company appears in contacts.state.relatedObjects, keyed by Ada's primary key. The calculated layer then reads Ada's row and her related company together. It builds a display label into contacts.state.calculatedObjects, keyed the same way.
Now the narrowing layers decide whether Ada appears at all. The filter layer runs your allowedFilter and excludedFilter against Ada, her related company, and her calculated label. If she passes, the search layer checks her against the current text query. If she still qualifies, the sort layer places her among the rows that remain.
Ada was one row the whole way through. No layer copied her, and no layer wrote to her. Each layer only decided whether she appears, where she appears, or what extra values hang beside her. That is the model in one sentence: same row, different view.
The layers in order
useList builds the chain in a fixed order. Each layer receives the previous layer's state as its parent:
- Instance (
useListInstance) owns the rows: identity bycontactId, insertion order, merging, and the CRUD actions exposed oncontacts. - Subscription (
useListSubscription) owns timing. It watchesprops.intendToListandprops.intendToSubscribetogether withprops.params, refetching or resubscribing as they change. Live events flow back into the instance. - Related (
useListRelated) addscontacts.state.relatedObjects, a per-row map of objects looked up from other collections by rule. - Calculated (
useListCalculated) addscontacts.state.calculatedObjects, per-row values derived from the row and its related objects. - Filter (
useListFilter) narrows membership with yourprops.allowedFilterandprops.excludedFilterfunctions. - Search (
useListSearch) narrows membership by text query, using a FlexSearch index built fromprops.textSearchRules. - Sort (
useListSort) reorders whatever remains byprops.orderByRules.
The manager returned by useList exposes the sort layer's state as contacts.state, the end of the chain. The contacts.managed property holds every layer, so contacts.managed.listFilter.state shows the list before search and sort apply.
useObject layers subscription, related, and calculated over an object instance the same way; single objects have no filter, search, or sort.
Using a layer on its own
You do not always need the whole chain, and the layers differ in how they get their parent.
A subscription builds its own instance. Pass props (and handlers, unless shared defaults cover them), and useListSubscription calls useListInstance for you. That instance is fully determined by those same inputs. Pass a listInstance you already made to drive that one instead. useObjectSubscription works the same way over useObjectInstance.
A derived layer never builds its parent. It takes a parentState you supply, because its parent is whatever upstream you assembled, which the layer cannot guess. So useListRelated, useListCalculated, useListFilter, useListSearch, and useListSort all require parentState and throw without it.
Layers wrap, they never replace
Each layer re-exposes everything from its parent state and overrides only what it owns. Filter, search, and sort swap in narrowed or reordered views of contacts.state.objects, contacts.state.order, and contacts.state.objectsInOrder. Related and calculated add their maps and change nothing else. Subscription adds contacts.state.intendToList, contacts.state.intendToSubscribe, and contacts.state.subscribed, and merges its loading and error status with the instance's. Whatever layer you read from, the upstream fields are still there.
Actions stay outside the state object. The actions contacts.list(), contacts.bulkDelete(), contacts.executeAction(), and contacts.clearError() come from the instance and subscription layers. The derived layers contribute no actions; they are views.
The instance owns row identity
Downstream layers hold references to the instance's rows, never copies. When a subscription event updates a contact, every layer shows the change at once. Filter and search change which contactIds appear; sort changes the order they appear in. No layer adds a row, and no layer writes to one. Related and calculated keep their derived data in side maps keyed by the primary key, off the row itself.
Which layer order matters
One rule genuinely constrains the order. A related or calculated layer must sit upstream of any rule that reads its values:
- Filter functions receive each row plus its related and calculated values.
- Search rules can index related and calculated values as well as row fields.
- Sort rules reach them through the
relatedItem.andcalculatedItem.key prefixes.
The reverse never holds. A related rule cannot see filter output.
The order among filter, search, and sort does not change what you render. Filter and search decide membership. Sort reorders whatever remains. None of the three reads another's result, so reversing them all yields the same contacts.state.objectsInOrder. useList fixes the order at filter, then search, then sort because it is predictable and leaves the reorder with the fewest rows to move. Treat that as a convention, not a correctness requirement.
One observable follows from it: inside useList, the search index is built from the rows filter let through.
Client-side layers see only the loaded rows
Everything past the subscription layer is client-side. Filter, search, and sort never refetch. They reshape only the rows the instance currently holds, the ones the last fetch and the subscription events left there. They never reach the full server-side collection.
WARNING
This matters most under pagination or partial loading. If the instance holds one page of contacts, a search or a sort ranges over that page alone. The result has loaded-set meaning, not application-wide meaning. A search that finds nothing means nothing matched among the loaded rows, not that no such contact exists on the server.
So the layer you choose depends on the scope you want. For a view over the rows already loaded, apply filter, search, or sort. For work across the whole collection, change the reactive props.params the subscription watches and let the server select the rows; see Filter a list.
Search results and the sort order are throttled, so they can lag slightly behind fast input. contacts.state.running reflects when the composed view is rebuilding.
Failure modes
- Subscription events for rows not in the list. An update or delete for a
contactIdthe list does not hold logs a console warning and is ignored. A create for one it already holds is ignored the same way. The list does not error. - Malformed events. An event with no data, or an action other than create, update, or delete, throws back into your subscribe handler. So does a create or update whose row lacks its primary key.
- Reading the wrong layer. Rendering a middle layer's state, such as
contacts.managed.listSearch.state, silently drops the layers after it. Rendercontacts.stateunless you want a partial view. - Missing
props.params. The subscription layer watchesprops.paramsto know when to refetch, so it needs that key present to react to. The props foruseListmust defineprops.params, even as an empty object; the subscription layer throws at creation without it. - Empty search behaviour. Search is a view over the loaded rows, not a fetch. With
props.textSearchRulesset and thesearchShowAllWhenEmptyoption passed touseListturned off, an empty query is a filter that admits nothing, so the list shows nothing. The default treats an empty query as no filter yet and shows every loaded row.
Where to go next
- Learning path: Build a reactive list covers the instance layer on its own.
- Tasks: Paginate a list and Filter a list build on the list instance; Register app-wide CRUD defaults shares handlers app-wide.
- Configuring the manager's own layers: Show related objects and Show calculated values set up the two enrichment layers. No task guide yet covers filter, search, and sort as
useListprops. Each layer's reference page documents its rule shape: listRelated, listCalculated, listFilter, listSearch, and listSort. - Related concept: The object pipeline applies the same layering to a single record, without the membership and ordering stages.
- Reference: useList documents the manager, and listInstance and listSubscription document the two layers that own actions.