Back to articles
ERPPublished: October 25, 2026Reading time: 9 min read

Building a Shared Data Table for an ERP: Who Owns the State?

How a client-side list component grew into separate client and server tables, why TanStack Table was tried and not adopted, and how table state moved from the URL to per-table persisted stores.

Frontend ArchitectureState ManagementTypeScript

Almost every screen in an ERP is a table first. Before a user edits a record, approves a request, or opens a child resource, they find it in a list. That made the shared table the most reused piece of frontend in the project, and also the one where every early shortcut came back as a problem on twenty other pages.

This is how that table grew from a client-side list component into two related implementations, why I tried a popular headless library twice and kept my own composition, and where the state of a table should actually live. Some of the answers took more than one attempt.

The first version processed everything in memory

The first table was a generic TypeScript component driven by typed column definitions. It received the full record list and processed it in one memoized pipeline: column filters, then global search, then sorting, then a slice for the current page. Row numbers were generated from the source position, so they stayed correct across pages without the domain record needing a number field.

const processed = useMemo(
  () => sortRows(searchRows(filterRows(rows, filters), query), sort),
  [rows, filters, query, sort],
);
const visible = processed.slice(page * pageSize, (page + 1) * pageSize);

For the early administration pages this was the right trade-off. The lists were small, the backend did not need a paging contract yet, and filtering felt instant. I deliberately kept filtering client-side and planned a server path for modules with different scale, instead of building it before any page needed it.

Removing domain knowledge from the table

The table did not stay simple for long, but most of the growth was about removing assumptions rather than adding features.

The first API had fixed onAdd, onEdit and onDelete props. Real screens needed approve, upload, open child records, download, and assign. I replaced the fixed callbacks with typed action descriptors, so the page supplies the icon, label, callback and required permission, and the table only renders them.

type RowAction<T> = {
  label: string;
  icon: ComponentType<SVGProps<SVGSVGElement>>;
  role?: string;          // checked against the active menu permission
  onClick: (row: T) => void;
};

The column and action contracts also used to accept a catch-all record type with every optional field any page had ever needed. I replaced it with generics over each feature's own view type, so a column key that does not exist on that record fails at compile time instead of rendering an empty cell.

The same idea applied to behavior. A table embedded inside a modal, such as a short list of files in a group, does not need search, sorting or pagination. Instead of a second component, the table received switches for those operations, while toolbar actions stayed available when search was off. Small screens got a card layout built from the same processed and paginated rows and the same row actions, so the mobile view could not drift from the desktop one.

The table stopped knowing what the page was about

Early table

  • fixed add, edit and delete props
  • one shared record type
  • always searchable and paged
  • desktop rows only

Shared table

  • typed action descriptors
  • generic over each page's view type
  • operations switched per table
  • mobile cards from the same rows

Trying TanStack Table, twice

TanStack Table is the obvious choice for this kind of component, and I evaluated it early. I decided to keep the custom table. Later, when I was building the server-backed path, I rewrote that table on top of TanStack Table for a short time, and then restored the custom composition in the next change.

The reason was not that the library was weak. A headless table model solves sorting state, column models and row models very well. In this project, those were already working and were not where the effort went. The effort went into RTL layout and pagination direction, the mobile card view, permission-aware actions, filter operators that had to be serialized for the backend, and a visual style already shared by many pages. Moving to the library would have kept all of that work and added an adapter layer between it and our components.

I would make a different call on a new project with no existing table, or with requirements such as column virtualization and grouping, which the library handles and our table did not need.

A server table, and who owns the query

Larger engineering datasets eventually needed real server paging. The backend contract was simple: the client sends page, pageSize, a filter and an orderBy, and receives the items with a total count. The backend used Gridify, so the frontend translated column filters, sort state and search into Gridify expressions.

// simplified example of a generated request
?page=2&pageSize=16
&filter=title=*pump,status=2
&orderBy=createdAt desc

The harder question was ownership. My first server table owned its own query and accepted a fetch callback. Then I moved paging, filtering and the query fully into the table so the page only declared columns. That felt clean until the table was hard-coded to one query and could not be reused. The final version moved data fetching back to the page: a hook builds the request parameters from table state, the page runs the query, and the table receives data, counts, loading and error state.

I also stopped trying to make one table do both jobs. The established client table kept its behavior for every existing page, and the server table became a separate component. They share the header, rows, mobile cards, action toolbar, breadcrumb and pagination, so the duplication is limited to the part that is actually different: where the data comes from and how state turns into a request.

The server table does not fetch anything
Persisted table state
Parameter hookdebounced filters, sort, search and page
Gridify builderturns state into filter and orderBy strings
Page queryowned by the page, keyed by the parameters
Server tablerenders data and reports state changes

Where table state should live

This took three versions. The first server table kept its paging, sort and filters in the URL query string. That made a table state shareable, but it mixed table state with route state and became awkward as soon as advanced search added nested conditions.

The second version moved the state into one persisted Zustand store. State survived navigation and refresh, which users wanted. But there was only one store, so every server table read and wrote the same state. It worked while one page used it and was wrong as soon as there were two.

The third version replaced the singleton with a store factory. Each table gets its own persisted store, keyed by the route it belongs to, and the store is injected into the table, the search bar and the parameter hook. The client table received an equivalent factory, and every client table now has to pass an explicit key.

const createTableStore = (key: string) =>
  create(
    persist(tableState, {
      name: `table-store-${key}`,
      // selection is live state, not a preference
      partialize: ({ selectedRows, ...rest }) => rest,
    }),
  );

That last line came from a later fix. Persisting selected rows meant an old selection could reappear after a reload and be used in a bulk action without the user noticing. Page, sort, search and filters are preferences worth restoring. A selection is not.

Table state and the workspace

The ERP has a tab-based workspace where normal navigation reuses one temporary tab. Once table state was persisted per route, a new problem appeared: a user could spend minutes building filters on a list and then lose that tab to the next sidebar click. The table now reports meaningful interaction, such as filtering, searching, sorting or changing page, and the workspace pins the temporary tab when that happens. Closing a tab is also meant to clear the stored state for its route.

Small bugs that shaped the contract

  • Buttons inside forms. Embedded tables live inside forms. Pagination, filter toggles and row actions without type="button" submitted the surrounding form. Every interactive control in the table is now explicitly form-safe.
  • What "select all" means. It went from the visible page, to the filtered dataset, to numeric IDs so selections survive paging. Storing whole row objects broke identity as soon as data was refetched.
  • Permissions on actions. Unauthorized actions were first only dimmed. The follow-up made them actually disabled, which is the difference between a hint and a rule. The backend still enforces access; this only stops the UI from offering what will fail.
  • A visible reset. The reset button shows a marker when any filter, sort or search is active. The selector returns a boolean rather than a new object, so it does not cause extra renders.

What is still open

I would rather list the known limits than pretend the table is finished. Advanced search with mixed AND and OR conditions is evaluated in order on the client but grouped on the server, so the two tables can disagree on the same query. Nested columns can be displayed but not sorted or filtered. Client and server stores share a key prefix, so a client table embedded in a page that also has a server table at the same route can collide. And tab cleanup currently removes the key of the active route, not always the key of the tab being closed. Each of these has a clear fix; none of them has been needed badly enough yet to justify the change.

The lesson I took from this table is that the expensive part of a shared component is rarely rendering. It is deciding who owns each piece of state, and keeping that decision the same on every page that uses it.