Server-side pagination pattern — the standard for every grid
Every master-data grid loads only the requested page from the server (default 15 rows) — changing page, page size, or sort triggers a new request, never a client-side re-slice of an already-fetched array. This is the standing default; build new grids this way unless explicitly told otherwise for that specific case.
Two shapes exist depending on whether the entity is territory-gated (see the Territory Access Pattern page).
:::danger Get this right — it's a real security boundary, not just a performance detail
Which of the two shapes below an entity uses is not a stylistic choice. Paging
a territory-gated entity before access filtering would leak inaccessible rows
into both the returned page and the TotalCount.
:::
The shared response shape
Application/Common/PagedResult.cs — the shared response shape:
{ Items: IReadOnlyList<T>, TotalCount: int }. Items is only ever as long as
the requested page size.
Ungated entities — paging on the repository
Ungated entities (City, Country, and any other Mas* table with no
MAS_USER_TERRITORY grant type) page directly on the repository —
I<Entity>Repository.ListPagedAsync(page, pageSize, sortKey, sortDescending, ct),
implemented by sorting/Skip/Take-ing the same cached full list ListAsync
already maintains (no extra DB cost, just a smaller response payload), via a
private ApplySort switch keyed on the frontend's sort-key strings.
Territory-gated entities — paging on the service
Territory-gated entities (Branch, Company, Client, Warehouse, Department,
Equipment, ForumType, Queue, TrCode, Address today) must page after access
filtering, never before. Paging lives on the service, not the repository —
<Entity>Service.GetAllPagedAsync(caller, page, pageSize, sortKey, sortDescending, ct)
resolves the caller's accessible rows exactly the way GetAllAsync already
does, then sorts/pages that filtered set.
:::caution Don't repeat this mistake
Do not add an unfiltered ListPagedAsync to a gated entity's repository —
one got added to several gated repositories during the initial rollout
(Equipment/ForumType/Queue/TrCode) before the gating requirement was noticed,
then had to be deleted once the correctly-gated GetAllPagedAsync made it
genuinely dead code; worse, it was a live foot-gun for a future endpoint that
might get wired to "the obvious paging method" without noticing it skips
access control entirely.
If a gated repository's paged read is ever justified (e.g. Address's, which needs an additional non-security filter alongside the access restriction), it must take the caller's already-resolved accessible-ID set as a parameter — the repository itself must never independently decide who can see what. :::
Controller convention — one route, two shapes
One route, two shapes, discriminated by an optional page param. GetAll
takes page (nullable int), pageSize (default 15), sortKey, sortDesc;
when page is null it returns the old plain-array shape (for
dropdowns/lookups elsewhere — e.g. another entity's own form), when page is
given it returns PagedResult<T>. Don't split this into two routes per entity.
Frontend: usePagedList + DataGrid
usePagedList (src/lib/usePagedList.ts) + DataGrid
(src/components/DataGrid.tsx). The hook owns page/pageSize/sortKey/sortDir
state and re-fetches whenever any of them change; DataGrid is
presentation-only (server-driven — columns declare sortable: true, not a
client-side sortValue function) and renders a page-size selector
(15/25/50/100) alongside the Prev/Next pager. Call reload() after a
successful create/update/delete instead of re-fetching manually. fetchPage
passed into the hook must be a stable reference (a plain imported API
function) — never an inline arrow function.
Transactional/operational grids default to newest-first
Added 2026-07-28, MasterOrder's list page — pass defaultSortDir: "desc"
alongside defaultSortKey to usePagedList (the option exists for exactly
this), descending on the entity's identity column (e.g. InboundNo) as the
simple, correct proxy for "newest" unless a real CreatedDt-style column is a
better fit.
This applies to Operations-pattern entities (Warehouse Order, MasterOrder, and
future transactional entities) — not a retroactive change to the existing
Mas* lookup grids (Cities, Countries, etc.), which correctly default to
alphabetical-by-code and shouldn't be resorted just for consistency's sake.
Reference files
Ungated pilot: PagedResult.cs, ICityRepository.cs, CityRepository.cs,
CitiesController.cs, usePagedList.ts, DataGrid.tsx, CitiesPage.tsx.
Gated: BranchService.cs/BranchesController.cs.