An admin table looks like one component. By the time you've shipped five, it's a system — and most of the work happens in places you didn't budget for. Here's the map.
The previous post named the floor problem: every admin product spends two or three months rebuilding the same eight things before anyone can build the actual product. Auth, RBAC, theming, routing, forms, notifications, feature flags — and tables. Eight items on the list. Eight things every team underestimates.
Today I want to take one of them apart. Not because tables are the most interesting item on the floor — they aren't — but because they are the most representative. The mistakes teams make on tables are the same mistakes they make on the other seven floor items, in miniature. If you understand what's actually inside a "table," the rest of the floor becomes legible.
A table looks like one component. By the time you've shipped five of them, it's a system. Most of the engineering happens in places you didn't budget for.
Here are the five problems hiding inside that system, in roughly the order they bite.
Problem 1: client-side data stops working
You start with const [rows, setRows] = useState<Customer[]>([]), fetch all of them on mount, filter and sort in memory. It works. It works for weeks.
Then one of three things happens. The dataset grows past 10,000 rows and the first render takes three seconds. Or the backend team adds a permission rule and "all of them" turns into "all of them this user can see" — a computed set the frontend can no longer fetch in one shot. Or a PM asks for "show me everything modified in the last 30 days," and you realize the frontend has to fetch the full dataset just to answer a question the database could answer in milliseconds.
The naive fix is to keep the same architecture and add a useMemo for the filtering. That buys a week. The real fix is to invert the relationship between frontend and backend: the table sends a query, the backend returns one page of results plus a total count. Pagination, sorting, filtering, and search all live on the server. The frontend holds maybe 50 rows at a time, regardless of how many rows exist.
This is the moment "the table" stops being a component and starts being a query model. And every subsequent problem on this list is downstream of that fact.
Coreola ships with this inversion done. The query shape is a small, opinionated type — page, page size, search, sort, filters — and every list view in the codebase speaks it. The customers list, the assessments queue, the audit log — all the same shape.
Problem 2: filtered views are unshareable
A user filters the customer list to "active, US, signed up in the last 30 days." They Slack the URL to a colleague. The colleague opens the link and sees the unfiltered list.
This is a small UX paper cut and a large indicator of a broken architecture. If the filter lives in component state (or Redux, or a Zustand store, or a Context), it doesn't exist in the URL, and the URL is the only thing the colleague received.
The fix is to make the URL the source of truth for table state. Page, page size, search, sort, filters — all serialized to query params. Component state mirrors the URL, not the other way around. Now a share link is a complete description of what the recipient should see. Bookmarks work. The back button works. "Send me what you're looking at" works.
This sounds obvious until you try to implement it. The first version is fine. The second version — with multi-select filters, ranges, and default values — gets messy. The fifth version has helper functions everyone else on the team is afraid to touch.
Coreola treats the URL as the table's interaction model. There's one hook that owns the URL ↔ state contract, including the cases that look small until they aren't: stripping default values from the URL (so share links don't grow garbage), resetting page to 1 on filter change (the most common URL-state bug), and using replace: true for filter edits but push for page navigation (so the back button does what users expect).
Problem 3: two tables on one page fight
A dashboard needs two tables side by side. Or a details view has a tab for "related items" that itself is a table. Now both tables want to use ?page=2 and they collide.
The naive fix is to give each table its own slice of state and skip the URL entirely for the second one. Now you have two tables with two different behaviors — the first is shareable, the second isn't — and engineers have to remember which is which.
The real fix is to scope URL keys per table. Instead of ?page=2, you write ?customers.page=2&assessments.page=0. Every table is identified by an ID, and that ID prefixes its keys. Three tables on the same page do not collide. A fourth table can be added without touching the first three.
This is one of those decisions you have to make on table #1, or you pay for it on table #5. Retrofitting it across an existing codebase means touching every table at once.
Coreola scopes everything by tableId. Multiple tables coexist on a page without any extra wiring. The customers detail view, which embeds an activity log table inside a tab on a page that already has a breadcrumb-driven layout, is a normal config — not a special case.
Problem 4: user preferences and share links collide
The user resizes their columns, hides one, and sets their preferred page size to 50. The next morning they reload — and everything is back to defaults. So you add localStorage. Now their preferences persist on their machine, but not on their phone. So you move it to the backend. Now it syncs across devices, and you're done.
Except now a colleague shares a filtered URL. Should the recipient see the filters from the URL but their own column layout? Yes — they're different concerns. The filters say "what data," the layout says "how the recipient prefers to look at data." These should never have been the same state.
So the system has two stores: ephemeral (URL — filters, search, sort, page) and durable (per-user — column visibility, widths, default page size, default sort, saved filter presets). And then there's a hydration problem: what happens when both have an opinion about the page size for this user, but the URL has filters that aren't in the user's saved defaults?
The rule that works: URL wins where it speaks. Stored preferences fill the gaps. If the URL specifies a page size, use it. If not, look at the user's saved default. If neither, fall back to the system default.
Implementing this without bugs requires a hydration phase that the data fetch waits for — otherwise the table fires its first query with the wrong filters, gets the wrong data, then fires a second query with the right filters and replaces the data. The user sees a flash. On a slow network it's two flashes.
Coreola separates the two stores explicitly and gates the data query on an isHydrated flag that goes true only after URL state and saved preferences have been reconciled. One fetch, with the right parameters. No flashes. Share links override defaults; defaults fill gaps; nothing leaks between users.
Problem 5: definitions get tangled
By table #3, the column array knows about the filter array. The filter array knows about the server query. The server query knows about the column array because some columns are computed from filtered relationships. Adding a column requires changes in four files.
This is the version of the floor problem that gets you in year two. It doesn't look bad at first — the codebase has tables, the tables work, features ship. But every new field touches every layer. Every refactor is high-risk. The team starts avoiding the table code.
The fix is to decouple three definitions that share only an id:
type ColumnDef = { id: string; label: string; render: (row) => ReactNode; ... };
type FilterDef = { id: string; label: string; control: 'select' | 'date-range' | ...; ... };
type QueryShape = { page; pageSize; sort; filters; search };
The column knows how to render a cell. The filter knows what control to show. The query knows what to send to the server. They share an id (matching the backend field name) and nothing else.
Now adding a sortable column is a one-line change. Adding a filter is appending to an array. Hiding a column doesn't touch filters. Disabling sort on one column doesn't ripple anywhere.
This is the part you cannot retrofit. If your codebase started with intertwined definitions, the only way out is a rewrite. Start decoupled.
Coreola keeps these three definitions strictly separate. The same <Table> component accepts a columns array, an optional filters array, and independent feature flags (withSearch, withExport, withColumnsResize). Every table in the codebase follows the same shape, which is the deeper payoff: the second engineer on the project reads three tables and predicts the fourth.
The pattern beyond tables
Five problems, one shape:
- The naive model breaks at real scale.
- State ends up in the wrong place (component, not URL).
- Two instances of the same thing collide because nothing scoped them.
- Two stores with overlapping responsibilities need a reconciliation rule.
- Definitions that should have been orthogonal grew couplings.
If you reread that list with "tables" replaced by "permissions," "forms," "notifications," or "feature flags," it's almost the same list. Permissions: the naive
if (user.role === 'admin')breaks the moment two users with the same role have different permissions on different rows. Forms: validation logic in the component vs. in the schema vs. in the server response — three stores that need a reconciliation rule. Notifications: a toast fired from inside a Redux thunk needs to reach a React provider that wasn't designed for it.
The floor isn't eight small problems. It's one structural problem with eight surface symptoms. And the work of building it well is, on every item, the same work: invert the naive model, put state where it belongs, scope what can collide, write a reconciliation rule where stores overlap, decouple what shouldn't have been one definition.
Doing that work once is a foundation. Doing it eight times is most of a year.
This was the table version. The next post is the permission version — same five-shaped problem, completely different surface. If you've ever written if (user.role === 'admin') and thought "this'll be fine," it's for you.
Coreola is a React admin foundation that has these five problems solved for tables — URL state, scoped per table, reconciled with user preferences, decoupled column/filter/query definitions, all wired into a working customers list and assessments queue. Live demo at demo.coreola.com.
Top comments (0)