React

Tables and Modals in Next.js 16: Preserving State

· 7 min read

Tables and Modals in Next.js 16: Preserving State
Contents

You browse an admin table to page 5, open a record, fix a typo, close the modal, and you’re back on page 1. Admin UIs are full of this pattern, a table to navigate database records and a modal to view or edit one of them, and it’s easy to get it subtly wrong.

The Next.js docs suggest Parallel Routes for modals, but that’s not the only way. I tried three setups on real admin screens. Here they are, with what each one costs.

1. Optional catch-all route#

The first version used an optional catch-all dynamic route:

.
├── employee
│   └── [[...id]]
│       └── page.jsx
├── layout.jsx
└── page.jsx

At /employee the page renders an Ant Design Table with data fetched on the server in page.jsx. After that, the table talks to a REST API for pagination, sorting and filtering.

Clicking Edit on a record navigates to /employee/[id], where id is the primary key in the MySQL table. When the route has an id, the same page renders a modal on top of the table, so the user edits the record without losing sight of the list:

// src/app/employee/[[...id]]/page.jsx

import EmployeeEditModal from "@/ui/components/Main/Employee/EmployeeEditModal"; 
import EmployeeTable from "@/ui/components/Main/Employee/EmployeeTable";
import { employeeModel as model } from "@/lib/models";

export default async function EmployeeHome({ params }) {
  const { id } = await params ?? {},
          [ pk ] = id ?? [],
          initialTableData = await model.findAll(),
          initialFormData = pk ? await model.find( pk ) : null;
          
  return (<>
    <EmployeeEditModal pk={ pk } initialFormData={ initialFormData } />    
    <EmployeeTable initialTableData={ initialTableData }  />
  </>);
}
Admin UI

Simple to implement and easy to reason about. The table is always there, and the modal shows up only when there’s a record to edit.

So what’s wrong with it? The table state doesn’t survive. Switch to another page of the table, open a record via /employee/[id], close the modal to get back to /employee, and the table component mounts again. The router treats /employee and /employee/5 as different instances of the page, so pagination, sorting and filters fall back to their initial values. That’s the page 5 to page 1 jump from the beginning.

We need routing that still gives the modal its own URL but leaves the table alone.

2. Intercepting routes#

Next.js has Intercepting Routes for that. They still use a slot (@modal), but instead of mounting the modal at the root of the application, as the docs example does, we put the slot into the layout of the table page.

Then /employee and /employee/[id] share the same layout instance. The table stays mounted while the modal opens on top, and pagination, sorting and filters survive the edit.

The folder structure, simplified:

.
├── employee
│   ├── @modal
│   │   ├── (..)employee
│   │   │   └── [id]
│   │   │       └── page.jsx
│   │   └── default.jsx
│   ├── layout.jsx
│   └── page.jsx
├── layout.jsx
└── page.jsx

employee/layout.jsx renders the table page and, when there is one, the modal. @modal/(..)employee/[id]/page.jsx is the intercepted route: it overlays the modal instead of navigating away, so moving between /employee and /employee/[id] doesn’t re-render the table.

The layout itself is tiny:

// src/app/employee/layout.jsx
export default function EmployeeLayout({ children, modal }) {
  return (
    <>
      { children }
      { modal }
    </>
  );
}

The table page loads employees from the database and renders the Ant Design table:

// src/app/employee/page.jsx

import EmployeeTable from "@/ui/components/Main/Employee/EmployeeTable";
import { employeeModel as model } from "@/lib/models";

export default async function EmployeeHome() {
  const initialTableData = await model.findAll();
  return (<>
    <EmployeeTable initialTableData={ initialTableData }  />
  </>);
}

And the intercepted route loads the selected record into the modal:

// src/app/employee/@modal/(..)employee/[id]/page.jsx
import EmployeeEditModal from "@/ui/components/Main/Employee/EmployeeEditModal"; 
import { employeeModel as model } from "@/lib/models";

export default async function EmployeeModalHome({ params }) {
  const { id } = await params ?? {},
          [ pk ] = id ?? [],
          initialFormData = pk ? await model.find( pk ) : null;
  return (<>
    <EmployeeEditModal pk={ pk } initialFormData={ initialFormData } />  
  </>);
}

This one has a catch too. Open /employee/[id] directly in the browser, or just reload the page with the modal open, and you get a 404. Interception only happens on client-side navigation, and there’s no regular route behind it. Why can’t a user bookmark a record or send a link to a colleague? In an admin UI that’s a real limitation.

The fix from the docs is to add a regular employee/[id]/page.jsx for direct visits. Then you maintain two pages for the same record, and the direct link shows the record without the table behind it. Not quite what I wanted.

3. Layout plus dynamic route#

The previous experiment showed that a dedicated layout for the entity keeps the table from re-mounting. To make every record reachable by URL, reload included, we need a plain dynamic segment instead of interception. So the idea is to combine the two: the layout holds the table, and a dynamic route holds the record.

.
├── employee
│   ├── [id]
│   │   └── page.jsx
│   ├── layout.jsx
│   └── page.jsx
├── layout.jsx
└── page.jsx

The table moves into the layout:

// src/app/employee/layout.jsx
import EmployeeTable from "@/ui/components/Main/Employee/EmployeeTable";
import { employeeModel as model } from "@/lib/models";

export const dynamic = "force-dynamic";

export default async function EmployeeLayout({ children }) {
  const initialTableData = await model.findAll();
  return (
    <>
      { children }
      <EmployeeTable initialTableData={ initialTableData } />
    </>
  );
}

page.jsx has nothing left to do:

// src/app/employee/page.jsx
export default function EmployeePage() {
  return null;
}

And [id]/page.jsx renders the modal, which ends up as children above the table:

// src/app/employee/[id]/page.jsx
import EmployeeEditModal from "@/ui/components/Main/Employee/EmployeeEditModal";
import { employeeModel as model } from "@/lib/models";

export const dynamic = "force-dynamic";

export default async function EmployeeModalPage({ params }) {
  const { id } = await params ?? {};
  const initialFormData = id ? await model.find( id ) : null;
  return <EmployeeEditModal pk={ id } initialFormData={ initialFormData } />;
}

Layouts in the App Router keep their state and don’t re-render on navigation. So opening and closing the modal no longer touches the table, and pagination, filters and sorting stay where they were. /employee/5 is a regular route, so it works after a reload or from a bookmark too, and it shows the modal on top of the table. force-dynamic keeps Next.js from prerendering the list at build time, since both pages read from the database.

But the layout not re-rendering cuts both ways. After the user saves the record, the table still shows the old data. I solve that by sending a signal from the modal to the table through the Redux store. The abstract modal component dispatches an update event on submit:

dispatch( dispatchUpdateTableEvent( "TABLE_NAME" ) );

The Redux Toolkit slice turns it into a timestamp-based hash per table:

	dispatchUpdateTableEvent: (state, action) => {
      state.updateTableHash[ action.payload ] = Date.now().toString( 36 );
    }

And the abstract table component watches that hash and refetches when it changes:

 const isMounted = React.useRef( false );
 const tableUpdateHash = useSelector( ( state ) => state.app.updateTableHash?.[ "TABLE_NAME" ] );
 
  useEffect( () => {
        if ( !isMounted.current ) {
            isMounted.current = true;
            return;
        }
        // Fetch when table params change or when external hash changes
        fetchData( tableParams );
    }, 
        [ tableUpdateHash, cleanFetchParams( tableParams ) ]
    );

The ref skips the first run, because the initial data already came from the server. In development, Strict Mode runs the effect twice, so you’ll see one extra request on mount. Production doesn’t have it.

The table refreshes only when something actually changed, and it never re-mounts. Side by side, the three setups compare like this:

Approach Table state survives Direct link to a record
Optional catch-all route no yes
Intercepting route in a @modal slot yes no, 404
Layout plus dynamic route yes yes

The third one is what I use for admin interfaces now. It’s plain App Router routing, no interception to keep in sync, and the only extra piece is the refresh signal after a save.