Anatomy of a list view: the overview composables in depth
Most business applications are, by volume, list views. A grid of vehicles, invoices, products; a search box; a filter panel; paging; row actions. It's the least glamorous screen in the app and the one users live in all day — and it hides more correctness traps than any form: overlapping fetches racing each other, error banners painting over fresh data, deleted rows that weren't actually deleted, back buttons that lose the user's filters.
This article dissects how the overview layer of the @regira/modules entities client solves those problems. It's a Vue 3 + Pinia CRUD client that mirrors a Regira.Entities back end, but the patterns — a shared state core, fetch-shape variants, a latest-wins guard, and the URL as the single source of filter truth — transfer to any stack.
The cast, smallest to largest:
| Composable | Role |
|---|---|
useOverviewCore |
Shared state + save/remove plumbing (no fetching) |
useSearchView |
Core + counted, paged fetching via GET /search |
useListView |
Core + plain list fetching via GET / |
useRouteOverview |
Syncs search state ⇄ URL query, refetches on navigation |
useFilter |
Filter panel mechanics (emit/reset/active detection) |
A view composes two or three of them; none of them knows about your components.
The core: state, and a deliberate split between apply and handle
useOverviewCore({ service, searchObject, defaultPageSize }) owns the refs every list needs:
const searchObject = ref<SO>(...) // current filter values
const pagingInfo = ref<IPagingInfo>(new PagingInfo(defaultPageSize))
const items = ref<Array<T> | undefined>()
const itemsCount = ref<number | undefined>()
const isLoading = ref(false)
const feedback = useFeedback() // success/fail banners
Note that items and itemsCount start as undefined, not []. That's a deliberate three-state design: not fetched yet is a different UI state from fetched, empty. The cost is that templates must guard the lazy refs — v-for="x in items ?? []", :count="itemsCount ?? 0" — and the payoff is that you can render a skeleton instead of a misleading "no results" flash on first load.
More interesting is the naming convention in what the core returns:
applySave(item): Promise<SaveResult<T> | undefined>
applyRemove(item): Promise<boolean>
handleSave({ saved, isNew }): void
handleRemove(item): void
apply* talks to the server; handle* mutates the local list. They are split because the two concerns fail independently. applySave calls service.save, reports success or failure through feedback, and — crucially — does not throw. It returns the SaveResult on success and undefined on failure. handleSave then splices the saved row into items (replace by $id for updates, push for inserts) without refetching the page.
Why not throw? Because in a list view the caller is almost always an event handler, and an unhandled rejection in @click gives the user nothing. Routing failures through a feedback channel that the view already renders means every error has exactly one, visible destination.
The split earns its keep on the delete path. Consider what happens when a user deletes a row that another table still references: the server correctly refuses with 409 Conflict. A naive implementation calls remove() then drops the row from the list — and now the UI shows a failure message and a list without the row, contradicting each other until the next refetch. The core's contract prevents it:
// applyRemove returns whether the row is actually gone
if (await applyRemove(item)) handleRemove(item)
The boolean return isn't a stylistic choice; it's the API making the wrong thing hard. The row only leaves the screen when the server agreed it left the database.
Two fetch shapes: useSearchView and useListView
Both extend the core with a fetch handler; the difference is the endpoint contract. A Regira.Entities controller exposes both on every entity:
GET /{entities}— the list endpoint, returns{ items };GET /{entities}/search— returns{ items, count }, wherecountis the total matching rows ignoring paging.
useSearchView targets /search and is what you want whenever there's a pager: count drives the page buttons, items is the current page. useListView targets the plain list and derives itemsCount from items.length — fine for short, unpaged lists, meaningless for pagers. The corollary on the .NET side is worth knowing: the list endpoint never counts, so point any pager at /search — this is exactly why the endpoint exists on simple and complex entities alike. One wiring detail makes it true on the client: the service reads the endpoint from config.searchUrl, which falls back to config.api when unset — so set searchUrl: api + "/search" in the entity's config, or useSearchView silently gets the uncounted list shape and itemsCount stays undefined.
Both handlers assemble the request the same way — current search object merged with current paging:
const so = { ...(searchObjectRef.value || {}), ...(pagingInfo.value || {}) }
if (resetPaging) so.page = 1
const { items: data, count } = await service.search(so)
An easy-to-miss detail sits in that first line: it spreads the ref, not the searchObject argument the composable received. The argument is only the initial value; the ref is what the filter panel has been mutating since. (Type-wise, reading .value off the plain argument even type-checks — ISearchObject extends Record<string, any> — and silently sends a request with paging only. The kind of bug you find in production, which is why the library reads the ref and says so in a comment.)
Both also ship a debounced variant (debouncedSearchHandler / debouncedListHandler, default 250 ms) for wiring directly to a search input's @input — debounce belongs at the handler level, not sprinkled through components.
Underneath, EntityServiceBase finishes the job: it merges config.baseQueryParams (per-entity defaults — the search object wins per key), defaults pageSize from config, drops null and $-prefixed keys, omits page when ≤ 1, and serializes arrays as repeated keys. Two conventions fall out of that: client-only scratch state can live on the search object under a $ prefix and never reaches the server, and pageSize: 0 means "give me everything" (the server's MaxPageSize still caps it — the clamp is enforced at the HTTP boundary precisely so a client can't escape it).
The race you will eventually hit: latest-wins
Here's the scenario that motivates the most subtle code in these composables. A user hard-reloads (or deep-links into) a list page in an authenticated SPA:
- The component mounts;
useRouteOverviewfires the initial fetch. The auth token is still being restored, so this request goes out anonymous — and will come back401. - Milliseconds later the token lands; the
onAuthenticatedhook triggers a second fetch. This one succeeds.
Two requests are in flight, and nothing guarantees their completion order. In the common bad ordering, the retry succeeds first and renders rows; then the original 401 lands, and its catch paints a failure banner — one that, unlike success toasts, deliberately does not auto-hide — over a perfectly loaded list. Users see an error on top of their data and, reasonably, file a bug.
The fix is small and worth memorizing — a monotonically increasing token; only the newest call may write:
let latestSearchId = 0
async function searchHandler(resetPaging = false): Promise<void> {
const searchId = ++latestSearchId
isLoading.value = true
try {
feedback.reset()
const { items: data, count } = await service.search(so)
if (searchId !== latestSearchId) return // superseded — drop silently
items.value = data
itemsCount.value = count
} catch (ex) {
if (searchId === latestSearchId) // a stale failure logs, but never paints
feedback.fail("fetching data failed", ...)
} finally {
if (searchId === latestSearchId) isLoading.value = false
}
}
Every write — data, error feedback, even the loading flag — is gated on "am I still the newest?". A superseded request still logs to the console (observability is free), but it cannot touch the screen. The same guard appears in useListView and in the details loader (useDetails), because the same overlap happens on detail pages: anonymous attempt 401s, post-login retry succeeds, stale banner threatens to cover the loaded form.
You can get the same effect with AbortController — cancel the old request instead of ignoring its result. The token approach has two advantages worth noting: it also covers the error path uniformly (an aborted request throws, which you then must special-case), and it works even when the underlying client doesn't thread cancellation through cleanly. Either way, the invariant is what matters: at most one in-flight operation may own the screen.
The URL is the filter state
The last piece, useRouteOverview, takes a position that pays off across the whole app: the route query string — ?q=volvo&brandId=3&page=2 — is the canonical copy of the list's search state. Component refs are just a working copy.
It works both directions:
State → URL. After the user changes a filter, updateOverviewRoute(resetPaging?) merges the search object and paging into the current route's query and pushes it. cleanQueryParams keeps the URL honest: null/undefined values drop out, $-prefixed client-only keys drop out, and page is omitted when it's 1. The result is a minimal, shareable URL.
URL → state. On mount, and on every route change within the same named route, routeSearchHandler parses the query back into { searchObject, pagingInfo } and runs the fetch:
const routeWatcher = watch(router.currentRoute, async (newRoute, oldRoute) => {
if (newRoute.name === oldRoute.name) { // same page, new query → refetch
await routeSearchHandler()
}
})
onMounted(routeSearchHandler)
Everything a list view needs falls out of this loop for free:
- Deep links — paste a colleague the URL, they see your exact filtered page;
- Back/forward — every filter change is a history entry; the browser's back button is the undo stack for filters;
- Refresh-safe — F5 reproduces the view;
- One fetch path — user interactions don't call the fetcher directly; they update the URL, and the watcher fetches. There is exactly one road to the server.
That last point is the architectural one. The filter panel doesn't fetch: useFilter just emits, and the overview's response to a filter event is updateOverviewRoute(true) — push the new query, reset to page 1. The route watcher notices, parses, fetches. Search box, pager, filter panel, back button and deep link all converge on the same code path, which is why they can't disagree.
Putting it together
A complete overview component, reduced to its wiring. The composables come from the entities client (npm i @regira/modules, imports from @regira/modules/vue/entities); service is your EntityServiceBase subclass for the entity, and its IConfig — api, searchUrl, defaultPageSize — is where the endpoints above were configured (a Pinia store is just a convenient place to pool one instance per entity):
import { useSearchView, useRouteOverview } from "@regira/modules/vue/entities"
const service = useVehicleStore().service // pooled service from the Pinia store
const {
searchObject, pagingInfo, items, itemsCount,
isLoading, feedback,
searchHandler, applySave, applyRemove, handleSave, handleRemove,
} = useSearchView<Vehicle, VehicleSearchObject>({
service,
searchObject: new VehicleSearchObject(),
defaultPageSize: config.defaultPageSize,
})
const { updateOverviewRoute } = useRouteOverview({
searchObject, pagingInfo,
defaultPageSize: config.defaultPageSize,
handler: searchHandler,
})
// filter panel emits `filter` → push URL (reset to page 1) → watcher refetches
function onFilter() { updateOverviewRoute(true) }
// pager emits page change → push URL → watcher refetches
function onPage(page: number) { pagingInfo.value = { ...pagingInfo.value, page }; updateOverviewRoute() }
// row deleted in a modal → server first, then the list
async function onRemove(item: Vehicle) { if (await applyRemove(item)) handleRemove(item) }
About fifteen lines of glue, and every hard problem in this article — races, contradictory delete states, lost filters, double fetch paths — is already handled below it.
The transferable checklist
Strip away the library specifics and five rules remain, worth applying to any list view in any framework:
- Separate server calls from local mutations, and make the local mutation conditional on the server's verdict — especially for deletes.
undefined≠ empty. Model not yet fetched explicitly, and guard your templates.- Latest-wins, enforced. Any screen that can issue overlapping reads needs a supersession guard on every write — data, errors and spinners alike.
- Count where you page. A pager driven by
items.lengthis quietly wrong; use an endpoint that returns the total. - Make the URL canonical. Filters, paging and search belong in the query string, and the fetch should be a reaction to the URL changing — one path to the server, shared by every trigger.
The details page and the form that this overview links into have their own composable — useForm, with its own set of hard-won decisions about cancel semantics, optimistic state and owned collections. That's the next deep dive.
Docs: regira.github.io/Regira-Modules — vue/entities: views, services, built-in features.