Skip to main content

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 repositoryI<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.