Mastering Tables and Modals in Next.js 16: Patterns That Preserve State

React
ui-image Mastering Tables and Modals in Next.js 16: Patterns That Preserve State
Image generated by ChatGPT (DALL·E)

Mastering Tables and Modals in Next.js 16: Patterns That Preserve State

When developing administrative UIs, one of the most common patterns is a table to navigate database records and modals to view or edit individual entries.

In Next.js, this can be achieved in several ways — not only with Parallel Routes, which are the officially recommended approach in the documentation.

In this article, I’ll share three different implementation strategies I explored and discuss their pros and cons based on real-world use cases.

1. Minimalistic Approach

In the first implementation, I used an optional catch-all dynamic route structure:

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

When visiting /employee, the page renders an Ant Design Table component populated from the database via server-side props in page.jsx. The table is connected to a REST API for parameter updates such as pagination, sorting, and filtering.

When a user clicks the Edit action on a record, the app navigates to /employee/[id], where id corresponds to the primary key in the MySQL table. If the route contains an id, a Modal component is rendered on top of the Table component, allowing users to edit the record inline without leaving the context of the list.

In this minimalistic approach, we handle both the table view and the modal within the same page component. The component checks if an id parameter is present in the route:

  • If id is provided, it renders the modal for editing the corresponding record.
  • The table is always rendered underneath, allowing users to maintain context of the list while editing.

Here’s how the implementation looks:

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

As you can see, this is a very straightforward design — simple to implement and easy to reason about.

However, this approach comes with a major drawback: the table state is not persistent.

When a user changes table parameters — for example, switching to another pagination page — and then opens a record for editing via /employee/[id], returning back to /employee (after closing the modal) causes the Table component to re-mount. As a result, the table’s state resets to its initial configuration. From a user’s perspective, this is inconvenient — you might have been browsing records on page 5, edited one entry, and then suddenly find yourself back on page 1 after closing the modal.

Clearly, we need a better approach that allows us to preserve the table state while still using clean routing for modal-based editing.

2. Approach with Intercepting Routes

Next.js also provides a feature called Intercepting Routes, which in our case fits much better than Parallel Routes.

This approach still makes use of route slots (such as @modal), but instead of injecting the modal at the root level of the application, we define it within the layout of the table page itself.

That means both the table view (/employee) and the modal view (/employee/[id]) share the same layout instance. As a result, the table component remains mounted while the modal opens on top of it — preserving pagination, sorting, and filters across edits.

A simplified folder structure looks like this:

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

In this setup:

  • The employee/layout.jsx defines a shared layout that renders both the table and any potential modals.
  • The @modal/(..)employee/[id]/page.jsx acts as an intercepted route — it overlays a modal instead of fully navigating away.
  • Navigating between /employee and /employee/[id] no longer causes a full re-render of the table, ensuring the user experience feels seamless and consistent.

Here’s the code implementation for this approach. First, we define a shared layout for the /employee route that renders both the table and the intercepted modal when present:

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

Next, the main table page loads employee data from the database and renders the Ant Design table component:

// 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 }  />
  </>);
}

Finally, the intercepted modal route loads the selected record and displays it in a modal overlay for editing:

// 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 } />  
  </>);
}

However, this approach also has a drawback. When you manually open a modal route like /employee/[id] directly in the browser, you’ll get a 404 page. From a user’s perspective, this can be frustrating — why can’t they bookmark or share a direct link to a record detail view? This limitation makes the setup less practical for real-world use.

3. Combined Layout and Dynamic Route Approach

From the previous experiment, we learned that having a dedicated layout for the entity (like /employee) helps preserve component state and avoid table re-mounts. However, to make each record view accessible by direct URL (with a page reload), we need a dynamic route segment instead of relying solely on intercepting routes.

So, the idea is to combine both approaches — use a layout to preserve shared UI and add a dynamic route for the entity detail page.

Here’s the resulting folder structure:

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

When opening and closing the modal, the table state remains intact, preserving pagination, filters, and sorting.

However, when a user saves updated data in the modal, the table needs to refresh to reflect the latest results. In this setup, I solve it by sending a signal via the Redux store from the modal to the table component.

For example, an abstract modal component could dispatch an update event on submit:

dispatch( dispatchUpdateTableEvent( "TABLE_NAME" ) );

The Redux Toolkit slice handles this by updating a timestamp-based hash:

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

Finally, the abstract table component listens for changes to this hash and triggers a data refresh 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 ) ]
    );

Conclusion

In this article, we explored three approaches for implementing tables with modals in Next.js:

  • Minimalistic optional catch-all route — simple but loses table state on navigation.
  • Intercepting routes with @modal slots — preserves table state but does not allow direct access via URL.
  • Combined layout + dynamic route — preserves table state and allows bookmarking/sharing record detail URLs.

To handle updates after editing records, a signal-based approach using Redux ensures the table refreshes only when necessary, without re-mounting the component.

This pattern provides a user-friendly, maintainable solution for administrative interfaces in Next.js, balancing clean routing, persistent table state, and modal editing.