Saved views
Capture the current layout — search, filters, sort, column visibility / order / width, pinning, density,
and page size — as a named view the user can switch between, update, and delete from a toolbar control.
Turn it on with enableSavedViews.
Change the layout (type a search, sort a column, hide one via its header ⋮ menu, change density), then open the Views control (the layers icon, top-left) → Save current as…. Switch between views from the same menu; a dot on the icon marks unsaved changes against the active view, and Update current view writes them back.
Enable it
<DataTable
columns={columns}
data={data}
enableSavedViews
stateKey="users-grid" // persists views under dt:users-grid:views (omit → in-memory)
enableGlobalFilter
enableColumnFilter
enableColumnVisibility
enableDensitySelector
/>
A view captures everything apiRef.current.layout.saveLayout() snapshots plus sorting and density —
i.e. the full user-visible layout. Selection and row expansion are intentionally excluded (transient).
Persistence
With a stateKey, the built-in views list is stored in localStorage under dt:<stateKey>:views
(separate from the grid's own dt:<stateKey> state blob), and restored on the next mount. Without a
stateKey and without controlled views, the list is in-memory only (lost on remount). Storage is
SSR-safe and resilient to disabled/quota-exceeded storage.
Controlled mode
Own the list yourself (e.g. to sync per-user views to a backend) by passing views — its presence
switches the feature to controlled mode, and the grid routes every change through your callbacks instead of
touching storage:
const [views, setViews] = useState<SavedView[]>([]);
const [activeViewId, setActiveViewId] = useState<string | null>(null);
<DataTable
columns={columns}
data={data}
enableSavedViews
views={views}
onViewsChange={setViews}
activeViewId={activeViewId}
onActiveViewChange={setActiveViewId}
/>
Imperative control
Everything the toolbar does is on apiRef:
apiRef.current?.views.saveView('Active admins'); // capture current layout as a new view, make it active
apiRef.current?.views.applyView(id); // switch to a saved view
apiRef.current?.views.updateView(id); // overwrite a view with the current layout
apiRef.current?.views.renameView(id, 'New name');
apiRef.current?.views.deleteView(id);
apiRef.current?.views.resetView(); // clear the active view + reset layout to default
apiRef.current?.views.listViews(); // SavedView[]
apiRef.current?.views.getActiveView(); // SavedView | null
apiRef.current?.views.isDirty(); // current layout diverges from the active view
Combine with external filters (own the whole view)
Saved views capture what's inside the grid. If you also have filters outside the table — a date
range, a status select, anything app-specific — you don't need the grid to manage them. Keep them in your app
and pair them with the grid's view state, which is already exposed on apiRef:
apiRef.current.layout.saveLayout()→ the full table view (SavedLayout: column visibility / order / width / pinning, row pinning, sorting, column + global filters, pagination, density).apiRef.current.layout.restoreLayout(state)→ applies it back (and refetches in server mode).
So a "view" in your app is simply { table: SavedLayout, filters: YourFilters } — persist it however you
like (localStorage, your API, Redux) and restore both halves together. No per-filter API, no duplicate
fetch logic, no limits on what a filter can be.
Use the built-in enableSavedViews for quick, table-only views. When a view must also span external
filters, own it yourself with saveLayout / restoreLayout (below) — the built-in control snapshots the
table state only.
Client-side data
Your external filters filter your own array; the grid just renders it. Save and restore are trivial:
import { useRef, useState, useMemo } from 'react';
import type { SavedLayout, DataTableApi } from '@ackplus/mui-tanstack-data-grid';
type Filters = { dateRange: [string, string] | null; status: string };
type MyView = { name: string; table: SavedLayout; filters: Filters };
function OrdersGrid() {
const apiRef = useRef<DataTableApi<Order>>(null);
const [filters, setFilters] = useState<Filters>({ dateRange: null, status: 'all' });
const rows = useMemo(() => applyMyFilters(allOrders, filters), [filters]); // your own filtering
const saveView = (name: string): MyView =>
({ name, table: apiRef.current!.layout.saveLayout(), filters }); // capture BOTH halves
const applyView = (view: MyView) => {
setFilters(view.filters); // your filter components restore
apiRef.current!.layout.restoreLayout(view.table); // the table restores
};
return (
<>
<DateRangePicker value={filters.dateRange} onChange={(dr) => setFilters((f) => ({ ...f, dateRange: dr }))} />
<StatusSelect value={filters.status} onChange={(s) => setFilters((f) => ({ ...f, status: s }))} />
<DataTable apiRef={apiRef} columns={columns} data={rows} />
</>
);
}
Server-side data
With onFetchData, read your external filters in the same fetch function — one place, no duplication.
Keep a ref for the latest values, and refetch when they change with apiRef.current.data.refresh().
Restoring a view refetches automatically (with the restored filters):
const filtersRef = useRef(filters); // always-latest values for onFetchData
// The one place external filters change: ref (for the fetch) → state (for the UI) → refetch.
const changeFilters = (next: Filters) => {
filtersRef.current = next;
setFilters(next);
apiRef.current?.data.refresh();
};
<DataTable
apiRef={apiRef}
columns={columns}
onFetchData={(tableState) => api.getOrders({ ...tableState, ...filtersRef.current })} // ONE fetch, both halves
/>
// Save / apply a view:
const saveView = (name: string) =>
myStore.add({ name, table: apiRef.current!.layout.saveLayout(), filters });
const applyView = (view: MyView) => {
filtersRef.current = view.filters; // restore external values first…
setFilters(view.filters);
apiRef.current!.layout.restoreLayout(view.table); // …restoring the table triggers one refetch
};
restoreLayout refetches only when the table state actually changes — so applying a view runs a single
request that already carries your restored external filters (they're read from the ref). If a view changes
only the external filters (identical table layout), call apiRef.current.data.refresh() after restoring.
Values you save must be JSON-serializable to persist (e.g. store dates as ISO strings).
Props
| Prop | Type | Description |
|---|---|---|
enableSavedViews | boolean | Show the toolbar Views control |
views | SavedView[] | Controlled views list (presence = controlled mode) |
onViewsChange | (views) => void | Views list changed (controlled) |
activeViewId | string | null | Controlled active view id (null = Default) |
onActiveViewChange | (id) => void | Active view changed (controlled) |
The synthetic Default view (always shown first) equals resetLayout(). A view's page size is applied
only when enablePagination is on, and its density is ignored when density/tableSize is a controlled
prop. A view that pins rows requires a stable getRowId (and client-mode data) to re-pin the right records.