Skip to main content

09 — useEnterpriseGrid decoded

Read this first: lesson 08 left you with a grid component that still needs columns, params, a toolbar, tabs and persistence wired around it on every page. This chapter reads the 700-line hook that does all of that wiring from one config object — and you will discover that most of its bulk is not features but defenses: against a localStorage race, against React function identities, and against a search box that erases filters. Code is quoted inline so you can read this without the repository open.

Time: about 40 minutes. Assumes lesson 08.

One object in, eight fields out​

A page calls useEnterpriseGrid(config) once. The config carries the required core — storageKey, columns (the lesson 07 column contract), defaultSearchField, buildParams, fetchPage, getRowId, sortableFields, emptyState — and a long tail of opt-ins: tabs, counts, row actions, import/export, saved views. The exhaustive option list lives in ../frontend/ag-grid.md; what matters here is the return value, because every page renders exactly these eight fields:

export interface UseEnterpriseGridResult<TRow, TApiParams> {
/** Search box + View▾ + Table▾ + primary action, as ONE node. Pass to
* <TabbedServerGrid toolbarRight={toolbar}> ONLY — never into sharedGridProps (Chakra Tabs
* keeps every panel mounted, so anything in per-tab gridProps renders once per tab). */
toolbar: ReactNode;
tabs: TabGridConfig<TRow, TApiParams>[];
/** Ref-free {value,label} pairs mirroring `tabs`, for any page-owned secondary tab strip
* (e.g. a card-view mode) that must never re-map the ref-carrying `tabs` array itself. */
tabLabels: { value: string; label: ReactNode }[];
activeTab: string;
onTabChange: (value: string) => void;
activeApi: () => GridApi<TRow> | null;
/** Purges EVERY tab's row cache. Use after a mutation that can move a row between tabs
* (e.g. archive/restore, status changes) — purging only the active tab leaves the other
* tabs showing stale data once the user switches to them. */
refreshAllTabs: () => void;
activeSelectionCount: number;
}

Read the JSDoc on toolbar and refreshAllTabs twice: each states a convention that lesson 10 turns into a rule. This chapter walks the machinery behind them instead. tabs feed TabbedServerGrid; activeTab/onTabChange make the page the tab controller; activeApi hands you the live GridApi for imperative work; activeSelectionCount gates the bulk-action bar — each tab is its own AG Grid instance with its own selection, so a tiny useTabSelectionCounts hook keeps one count per tab and switching tabs zeroes the count you are leaving.

The migration that must win a race​

The first thing the hook body does looks like a mistake — a useState whose initializer performs a side effect and whose state is never read:

// Runs during this hook's first render — i.e. before any ServerDataGrid child mounts and reads
// its state key on grid-ready. A useEffect here would not reliably win that race.
useState(() => {
if (config.legacyGridStateKey) {
migrateLegacyGridState({
legacyKey: config.legacyGridStateKey,
storageKey: config.storageKey,
tabValues: tabDefs.map((t) => t.value),
legacyTabValues: config.legacyGridStateTabs,
});
}
return true;
});

The comment is the whole design. Pages that predate the hook saved their column widths, order, visibility and filters under old localStorage keys; moving onto the hook renames the key, so without a copy every user's saved layout is orphaned. The copy must land before any child grid reads its state key on grid-ready — and an effect runs after children mount, so an effect loses. A state initializer runs during the parent's first render, before any child exists. That is the only ordering React guarantees, so that is what the code uses.

The migration itself, in legacyGridState.ts, is idempotent, never clobbers state already built on the new key — and deliberately drops one thing:

/**
* Drops any persisted sort. A saved sort can name a column that is no longer sortable — the
* Items grid's `totalStock` is the live example: it is computed from a subquery, so sorting it
* 500s, which is why it was removed from `sortableFields`. Restoring that state would hand the
* user back the exact broken request. Widths, order, visibility and pinning all survive.
* /* … */
*/

An incident, named in the code, justifying the scrub. Its behavior is pinned by legacyGridState.test.ts right next to it.

Stable identities for unstable functions​

Every page passes functions in its config, and pages write them inline — getRowId: (r) => String(r.id) gets a brand-new identity on every render of the page.

Predict: suppose the hook forwarded config.fetchPage and config.getRowId straight into the shared grid props. The page re-renders for an unrelated reason — a drawer opens, a count query resolves. What does the user standing at the grid lose? Write your answer down before reading on.

The in-code comment resolves it:

// Callers routinely pass fresh inline functions each render (getRowId={r => String(r.id)},
// handleRowAction, onRowDoubleClicked). If those identities flowed straight into
// sharedGridProps, the memo below would recompute every render and hand AG Grid new
// rowSelection / callback object references — which AG Grid re-applies via setGridOption,
// tearing down transient UI like an open, half-typed column filter. Ref-backed stable
// wrappers keep sharedGridProps referentially stable while always calling the latest fn.
const fetchPageRef = useRef(config.fetchPage);
/* … three more refs … */
useEffect(() => {
fetchPageRef.current = config.fetchPage;
/* … */
});
// useCallback (not useRef().current) so the ref is only read when the wrapper is CALLED,
// never during render — satisfying react-hooks' refs rule while keeping a stable identity.
const stableFetchPage = useCallback(
(params: TApiParams) => fetchPageRef.current(params),
[]
);

The user loses an open, half-typed column filter popup — AG Grid tears down transient UI whenever options are re-applied. So the hook keeps a ref per function, updates it every render, and hands the grid a wrapper whose identity never changes but whose behavior is always current. The same identity discipline covers grid state: apiRefs is a Map holding one GridApi per tab (each tab's onGridReady registers itself), activeApi() reads it through activeTabRef, and refreshAllTabs walks the map calling purgeInfiniteCache() on every entry — the mechanics behind that JSDoc warning about rows moving between tabs.

View options are the user's memory of the page​

useGridViewOptions owns everything in the View▾ menu — visibleColumns, activeSearchField, showTabs, showSelectionColumn, density — persisted as one JSON blob under storageKey. Density is just a lookup into a row-height map:

const DENSITY_SIZES = {
compact: { rowHeight: 44, headerHeight: 44 },
comfortable: { rowHeight: 56, headerHeight: 48 },
expanded: { rowHeight: 76, headerHeight: 48 },
} as const;

The one decision worth reading is in the visibleColumns initializer:

const stored = loadStored(storageKey).visibleColumns;
// A stored set wins outright — a returning user keeps exactly the columns they chose, so
// columns added to a page later stay off for them until they turn them on.
return new Set(stored && stored.length > 0 ? stored : (defaultColumns ?? allColumns));

A returning user's layout beats the page's defaults, always — shipping a new column never rearranges anyone's screen. Turning showTabs off collapses the returned tabs array to its first entry, which is also why a tab-less page just omits config.tabs: the hook substitutes a single implicit all tab and renders no tab UI.

Hiding a column changes the network request​

Visible columns are not cosmetic. The hook folds them into the request itself:

const fieldsParam = useMemo(
() => toFieldsParam(config.columns, visibleColumns),
[config.columns, visibleColumns]
);

fieldsParam names the columns the backend should project (lesson 07) — hidden columns are never fetched. That means toggling a column must refetch, so a change to visibleColumns triggers refreshAllTabs() — but only after the first render, because the mount-time value is just the restored state, not a user action:

const isFirstColumnsRender = useRef(true);
useEffect(() => {
if (isFirstColumnsRender.current) {
isFirstColumnsRender.current = false;
return;
}
refreshAllTabs();
}, [visibleColumns, refreshAllTabs]);

fieldsParam then flows into buildParamsByTab: the hook calls your buildParams once per tab, handing each tab its fixedFilterValue and extraParams, and keeps the resulting closures in a Map so each tab's grid owns a params-builder already specialized for that tab's filter and the current column projection:

tabDefs.forEach((tab) => {
map.set(
tab.value,
config.buildParams({
fixedFilterValue: tab.fixedFilterValue,
fields: fieldsParam,
extraParams: tab.extraParams,
})
);
});

Tab counts are queries with a shelf life​

Tabs that set showCount get a "(N)" suffix, and the numbers are ordinary React Query state — not props, not grid state:

const countQueries = useQueries({
queries: countableTabs.map((tab) => ({
queryKey: [config.storageKey, "count", tab.value],
queryFn: () =>
config.countQueryFn!({
fixedFilterValue: tab.fixedFilterValue,
extraParams: tab.extraParams,
}),
staleTime: 30_000,
})),
});

useQueries because the number of tabs is data, not code — you cannot call useQuery in a loop. The key [storageKey, 'count', tab.value] makes counts addressable from outside: a page whose mutation changes a count lists that key in invalidateOnRefresh, and the Table▾ menu's Refresh invalidates it. The 30-second staleTime accepts a briefly stale badge in exchange for not re-counting on every tab flick. Note countQueryFn receives the same tab context as buildParams — the count and the rows can never disagree about what a tab means.

Quick search is one column's filter, on a delay​

The toolbar search box does not talk to the backend. Keystrokes land in searchValue, and 300ms after the last one, a debounced effect calls applyQuickSearch from filterUtils.ts:

/**
* Applies a toolbar "quick search" box to a single column's existing text filter, leaving every
* other column's filter untouched. There's no backend "search across everything" param on these
* endpoints, so a search box maps onto whichever field is most useful to search per page (e.g.
* applicant name, position title) rather than promising a true full-text search.
*/
export function applyQuickSearch(api: GridApi | null | undefined, field: string, value: string): void {
if (!api || api.isDestroyed()) return;
const model: Record<string, unknown> = { ...api.getFilterModel() };
const trimmed = value.trim();
if (trimmed) {
model[field] = { filterType: 'text', type: 'contains', filter: trimmed };
} else {
delete model[field];
}
api.setFilterModel(model);
}

The rule: merge, never replace. The search occupies exactly one column's slot in the AG Grid filter model; every filter the user set through column headers survives. Because it is just a filter-model write, everything downstream — the datasource refetch, the lesson 08 persistence — comes for free. filterUtils.ts also holds the extractors that translate filter models into API params, and filterUtils.test.ts beside it is where that contract is pinned down — change the model shape and a unit test fails before a page does.

The debounce effect deliberately skips its mount run, and the comment explains the near-miss:

// Skip the mount run: the search box always starts empty (it isn't persisted), so applying it
// would only ever write an empty filter — and 300ms in, that lands right after ServerDataGrid
// restores the saved filter model, silently wiping the search column's persisted filter and
// saving the emptied model back. Only a real keystroke should touch the quick-search column.
const searchAppliedOnceRef = useRef(false);
useEffect(() => {
if (!searchAppliedOnceRef.current) {
searchAppliedOnceRef.current = true;
return;
}
const handle = setTimeout(() => {
applyQuickSearch(activeApi(), activeSearchField, searchValue);
}, 300);
/* … cleanup clears the timeout … */
}, [searchValue]);

An empty write, landing 300ms after state restore, would erase the restored filter and then persist the erasure — corruption that survives reloads. Two more edges are handled the same way, by reading AG Grid's actual behavior instead of hoping. Switching the search field explicitly clears the old field's slot before writing the new one. And picking a hidden field reveals its column first:

/**
* Points the toolbar search at a field, revealing its column first if it is hidden.
* Hidden columns are dropped from `columnDefs` entirely, and AG Grid silently discards
* filter-model entries for columns that do not exist — so searching a hidden field would
* otherwise type into a box that can never match anything.
*/
const handleSearchFieldChange = useCallback(
(field: string) => {
setVisibleColumns((current) =>
current.has(field) ? current : new Set(current).add(field)
);
setActiveSearchField(field);
},
[setVisibleColumns, setActiveSearchField]
);

Where this shows up in MotorPH​

Recap​

  • Wiring a new page is one call: give the hook storageKey, columns, defaultSearchField, buildParams, fetchPage, getRowId, sortableFields and emptyState; tabs, counts, actions and saved views are opt-in — the hook substitutes a single implicit all tab when you omit tabs.
  • The eight result fields split into render, control and imperative: toolbar/tabs/tabLabels you render, activeTab/onTabChange you pass to the tab strip, activeApi/refreshAllTabs/activeSelectionCount you call — and the JSDoc on toolbar and refreshAllTabs carries the two conventions lesson 10 enforces.
  • Config functions may be inline; the hook ref-wraps them — because a changed identity makes AG Grid re-apply options and tear down an open, half-typed filter popup.
  • Everything user-configured lives under storageKey, which is why the legacy migration runs in a useState initializer: it must beat the child grid reading its state key, and an effect would not reliably win that race.
  • Quick search merges one column's filter slot and skips its mount run — an empty write 300ms after state restore would wipe and re-persist the restored filters; the model contract is pinned in filterUtils.test.ts.

Next: 10 — Tabs, toolbars, and the two load-bearing conventions.