Skip to main content

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

Read this first: the tab strip on every list page turns out to be the smallest file in the grid system — and the source of its two most-broken rules. You will read TabbedServerGrid in full, find both rules written into the code as comments, and watch one page follow a rule in one handler and miss it in the next.

Time: about 35 minutes. Assumes lesson 09.

The whole component​

TabbedServerGrid.tsx fits in one quote. Read all of it — every convention in this lesson falls out of its shape:

import type { ReactNode } from 'react';
import { HStack, Tabs } from '@chakra-ui/react';
import { ServerDataGrid } from './ServerDataGrid';
import type { ServerDataGridProps } from './ServerDataGrid';

export interface TabGridConfig<TRow, TApiParams> {
value: string;
label: ReactNode;
gridProps: ServerDataGridProps<TRow, TApiParams>;
}

export interface TabbedServerGridProps<TRow, TApiParams> {
tabs: TabGridConfig<TRow, TApiParams>[];
defaultTab?: string;
variant?: 'enclosed' | 'line' | 'plain';
onTabChange?: (value: string) => void;
/** Rendered on the same row as the tab list (right-aligned) — e.g. a page-level "Add" button. */
toolbarRight?: ReactNode;
}

export function TabbedServerGrid<TRow, TApiParams = Record<string, unknown>>({
tabs,
defaultTab,
variant = 'enclosed',
onTabChange,
toolbarRight,
}: TabbedServerGridProps<TRow, TApiParams>) {
if (tabs.length === 1) {
return <ServerDataGrid<TRow, TApiParams> {...tabs[0].gridProps} toolbarRight={toolbarRight} />;
}

return (
<Tabs.Root
defaultValue={defaultTab ?? tabs[0]?.value}
variant={variant}
onValueChange={({ value }) => { onTabChange?.(value); }}
>
<HStack justify="space-between" align="flex-end" wrap="wrap" gap={3} mb={3}>
<Tabs.List>
{tabs.map(tab => (
<Tabs.Trigger key={tab.value} value={tab.value}>
{tab.label}
</Tabs.Trigger>
))}
</Tabs.List>
{toolbarRight && <HStack gap={2} wrap="wrap">{toolbarRight}</HStack>}
</HStack>
{tabs.map(tab => (
<Tabs.Content key={tab.value} value={tab.value} pt={3}>
<ServerDataGrid<TRow, TApiParams> {...tab.gridProps} />
</Tabs.Content>
))}
</Tabs.Root>
);
}

Three shapes to register. One ServerDataGrid per tab — each tab is a full grid instance with its own row cache and its own gridStateKey (lesson 08). The toolbarRight slot renders once, in the HStack beside the tab list, outside every panel. And a single tab short-circuits to a bare grid: no Tabs.Root, no strip — which is how the View menu's "Show status tabs" switch collapses a tabbed page without a second code path.

That first shape is the expensive one. A page with eight tabs mounts eight grids, and Chakra's Tabs.Content hides inactive panels rather than unmounting them. Both conventions exist because of that fact.

Convention one: the toolbar renders once, beside the tabs​

The 700-line hook from lesson 09 hands you a toolbar node, and its result type tells you exactly where it may go:

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;
/* … */
}

The older, simpler toolbar hook — still used by pages that never adopted useEnterpriseGrid — states the same rule with the full reasoning:

/**
* Produces a single combined toolbar node (search + refresh + extra actions + primary action) to
* pass into `TabbedServerGrid`'s `toolbarRight` prop, which renders once next to the tab list.
*
* Deliberately NOT meant for `ServerDataGrid`'s own per-tab `toolbarLeft`/`toolbarRight` props:
* `TabbedServerGrid` mounts one `ServerDataGrid` per tab and Chakra keeps every `Tabs.Content`
* panel in the DOM (hidden, not unmounted), so anything spread into `sharedGridProps` gets
* duplicated once per tab. Render this once at the page level instead.
*/
export function useGridToolbar({ /* … */ })

Two files carry the same warning because the mistake was made twice — once during the recruitment toolbar rollout, once again when the grids were consolidated.

Predict: ignore both comments and spread toolbarRight: toolbar into the shared per-tab grid props on an eight-tab page. What appears on screen, and what happens to text typed into the search box? Write your answer down before reading on.

Resolved: eight copies of the toolbar exist, one inside each panel, each a live React subtree with its own state and its own debounce timer. You only see one at a time — the hidden panels are hidden, not unmounted — which is what makes the bug slow to notice. The copy you see changes with the tab, so the search text you typed on Open is simply absent on Draft: it lives in another tab's copy. Menus double-render into portals, and the search state fragments per tab.

The rule: the toolbar goes in TabbedServerGrid's toolbarRight, never into per-tab grid props. Per-tab toolbarLeft/toolbarRight on ServerDataGrid exist only for controls that genuinely differ per tab.

Convention two: a moved row invalidates every tab​

Each tab's grid caches its own rows. Tabs are status filters, so a status mutation teleports a row from one tab's result set into another's — and only one of those grids saw the mutation happen. The hook ships the fix, with the rule in its JSDoc:

/** 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;

There is a second cache: the "(N)" count on each tab label is a react-query query keyed [storageKey, 'count', tab.value] with a 30-second staleTime. Invalidating the two-element prefix [storageKey, 'count'] hits all of them at once — react-query matches keys by prefix.

JobRequisitions.tsx shows the convention followed and missed in the same file. The bulk status handler does it right, and says why:

await Promise.all(selected.map(row => statusMutation.mutateAsync({ id: row.id, payload: { status } })));
toaster.create({ title: `${selected.length} requisition(s) set to ${status}`, type: 'success' });
api?.deselectAll();
// Status changes move rows between status tabs, so refresh all of them, not just this one.
refreshAllTabs();
void queryClient.invalidateQueries({ queryKey: [GRID_STORAGE_KEY, 'count'] });

The bulk delete handler, a page-scroll above it, does not:

await Promise.all(selected.map(row => deleteMutation.mutateAsync(row.id)));
toaster.create({ title: `${selected.length} requisition(s) deleted`, type: 'success' });
api?.deselectAll();
api?.purgeInfiniteCache();

That purges the active grid only. But a deleted row also lived in the All tab's cache — and in one status tab's, if you deleted from All — and it is still counted in every "(N)" label. Delete three drafts from the Draft tab, switch to All: the three rows are still there, and the counts still include them, until something else forces a refetch. A delete moves rows between tabs in the only direction people forget: out of all of them. It needs the same refreshAllTabs() + count-invalidation pair as the status handler.

The rule: if a mutation can change which tab a row belongs to — status change, archive, restore, delete, create — call refreshAllTabs() and invalidate [storageKey, 'count']. purgeInfiniteCache() on the active grid is only enough for edits that keep the row where it is.

The two menus, by contents​

The toolbar's two dropdowns split along one line, stated in GridViewOptionsMenu's JSDoc: "Kept separate from TableActionsMenu since these are display/configuration concerns, not one-shot actions."

View ▾ is how things look: the Saved Views group, a column checklist (with Select All, and a guard that keeps at least one column visible), a "Search In" radio picking which single field the search box targets, a density radio, and two switches — "Show status tabs" and "Show selection checkboxes". One non-obvious note from its JSDoc: the column checkboxes control "which columns are visible (and fetched — column checkboxes drive the backend's field-projection param, not just client-side AG Grid visibility)". Unticking a column shrinks the query.

Table ▾ is what happens once: Import CSV, Export CSV, Export PDF, any module-specific extraActions (Employees' BIR Alphalist export lives here), then Refresh, Reset View, and a View Analytics link. Its JSDoc gives the layout reason: grouping them "behind one trigger so they stop competing with the primary '+New' button on the toolbar row."

The exhaustive prop lists live in the AG Grid reference; this is the map, not the inventory.

Saved views are rows in a table​

"Save Current View…" does not write localStorage. The API wrapper is the whole story:

export const listSavedViews = async (entityKey: string): Promise<SavedViewDto[]> => {
const { data } = await apiClient.get<SavedViewDto[]>('/api/saved-views', { params: { entityKey } });
return data;
};

/** Saving under an existing name overwrites that view rather than creating a duplicate. */
export const saveSavedView = async (payload: SavedViewRequest): Promise<SavedViewDto> => {
const { data } = await apiClient.post<SavedViewDto>('/api/saved-views', payload);
return data;
};

export const deleteSavedView = async (id: number): Promise<void> => {
await apiClient.delete(`/api/saved-views/${id}`);
};

Views are scoped by entityKey — the savedViewEntityKey a page passes to the hook, like 'job-requisitions' — and save is an upsert by name, so re-saving "My weekly triage" updates it instead of breeding duplicates. The backend entity's Javadoc explains why the server stays dumb:

/**
* A user's named grid view (filters + visible columns + sort + density) for a given grid,
* identified by {@code entityKey} (e.g. "job-requisitions"). Grid-state payloads are opaque
* JSON strings owned by the frontend/AG Grid — the backend stores and returns them verbatim
* rather than modelling their shape, so any page can adopt saved views without a schema change.
*/

Four TEXT columns — filter_model, visible_columns, sort_model, density — that the server never parses. Any grid opts in by picking a key; no migration required.

Applying a view is where order matters. The hook's handler restores in a fixed sequence:

// Restore view options first (columns/density drive colDefs), then grid-level state.
if (view.visibleColumns)
setVisibleColumns(
new Set(view.visibleColumns.split(",").filter(Boolean))
);
if (view.density) setDensity(view.density as typeof density);

const api = activeApi();
if (api) {
api.setFilterModel(/* … parsed view.filterModel … */);
/* … then applyColumnState with the saved sort … */
}

View options first, because visible columns generate the column definitions — and a comment elsewhere in the same hook records that AG Grid silently discards filter-model entries for columns that do not exist. Apply the filter model before the column exists and the saved filter evaporates without an error. Filters before sort, so the sort applies to the filtered set the user saved.

What is remembered where​

Three stores, three scopes — confuse them and you will debug the wrong machine:

Saved viewsView-options blobPer-tab grid state
Livesbackend saved_view tablelocalStorage under storageKeylocalStorage under `${storageKey}-${tab}-grid-state`
Scopeper user, per entityKey — follows your login to any browserthis browser onlythis browser, this tab
Holdsnamed bundle: filters + columns + sort + densityvisible columns, search field, tab/selection switches, densitycolumn widths and order, filter model
Appliedon demand, when picked from View ▾every mounton grid-ready, per tab

A user who asks "why did my filters disappear on my laptop?" was relying on the right-hand two columns. Only a saved view travels.

Where this shows up in MotorPH​

Recap​

  • The toolbar goes in TabbedServerGrid's toolbarRight, never per-tab grid props — Chakra keeps every panel mounted, so a toolbar in sharedGridProps becomes N live copies with N separate search states, and typed text vanishes on tab switch.
  • A mutation that can move a row between tabs calls refreshAllTabs() and invalidates [storageKey, 'count'] — purging only the active grid leaves other tabs showing moved or deleted rows and every "(N)" label wrong; JobRequisitions.tsx's bulk delete is the standing counter-example.
  • View ▾ configures, Table ▾ acts — display concerns and one-shot actions are separate menus so neither competes with the primary button.
  • Saved views are backend rows, upserted by name, opaque to the server — which is why they follow a login across devices while column widths and the view-options blob stay on one machine.
  • Apply a saved view options-first — columns and density generate the colDefs, and AG Grid silently drops filter entries for columns that do not exist yet.

Next: 11 — Employees.tsx decoded: columns, tabs, params.