iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📷

Implementing Modals with Parallel and Intercepting Routes: Learning from the NextGram Official Example

に公開

NextGram

One of the official implementation examples provided by Next.js is NextGram.

In NextGram, depending on the conditions, you can open a modal while keeping the original page in the background, or switch to displaying only the content within the modal as a full page.

https://nextgram.vercel.app/

https://github.com/vercel/nextgram

In this article, I would like to look at this implementation example and the related App Router features, Parallel Routes and Intercepting Routes.

Modals in App Router

NextGram is an official sample application that displays modals by combining Parallel Routes and Intercepting Routes in App Router.
The modal implemented in this app addresses the following concerns associated with traditional modal implementation methods:

  • Content displayed in the modal cannot be shared via URL
  • Modal content disappears when the page is updated via browser reload, etc.
  • Using the browser's back/forward buttons causes a full page transition instead of the modal disappearing/appearing.

Implementation that resolves these concerns is achieved by switching between displaying content within a modal or displaying it as-is without a modal, depending on the navigation method.

The routing methods used to switch display content based on the navigation method are Parallel Routes and Intercepting Routes.
Before diving into the modal implementation, I'd like to check the types of screen transitions and these routing methods.

Types of Screen Transitions

Next.js has two types of screen transitions: Hard Navigation and Soft Navigation.

Hard Navigation

Hard navigation refers to page transitions that involve a browser reload, such as refreshing via the browser's update button or navigating via window.location.href.
React state is not preserved.

Soft Navigation

Soft navigation is a screen transition unique to SPAs, such as transitions via the Next.js <Link> component or push() from useRouter.
Since it does not involve a full page reload by the browser and only re-renders the necessary parts, it provides a fast and comfortable user experience.
https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#5-soft-navigation

Parallel Routes

Parallel Routes allow you to simultaneously render page.tsx from multiple routes within the same layout.tsx.
Of course, you can render just one page.tsx or decide whether to render each one based on certain conditions.

To use Parallel Routes, you must explicitly indicate their use by introducing what are called "slots" into your file structure.

https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

slots

Using Parallel Routes can be declared through slots.
Slots are represented in the @folder format, and the "page" contained in a slot is passed to the parent layout.tsx like a prop.

While the parent layout.tsx originally receives children, it will now also receive folder in parallel. children itself can be implicitly considered a slot (@children).
By adding conditional logic here, it is also possible to switch which elements are rendered.

layout.tsx
export default function Layout({
  folder,
  children,
}: Readonly<{
  folder: React.ReactNode;
  children: React.ReactNode;
}>) {
  return (
    <>
        <section>{children}</section>
        <section>{folder}</section>
    </>
  );
}

Slots do not affect the URL structure.

In Parallel Routes, as the name suggests, multiple pages are rendered in parallel.
Since slots do not affect the URL, multiple pages correspond to the same URL. This is how the simultaneous rendering of multiple pages is achieved.

active state

Next.js tracks what is called the active state (display/hidden status) for each slot.
The active state refers to the state of whether the page belonging to the slot was being rendered.

In Parallel Routes, when a soft navigation to a different level occurs while a certain page is being displayed, Next.js continues to display the page that was shown before the transition, even if no page corresponding to the URL exists for that slot.
In other words, it tracks which page was active (displayed) for each slot, and this is what is known as the active state of each slot.

However, when a hard navigation such as a browser reload occurs, the active state can no longer be tracked. In this case, for URLs that do not have a corresponding page, the content of default.tsx is displayed for that slot instead.
If you do not want to display anything, you need to return null in default.tsx (if you do not provide default.tsx, an error page will be displayed).

default.tsx
export default function Default(){
    return null
}

Switching content display based on navigation method

In Parallel Routes, the content displayed can change depending on the type of navigation.
For example, consider a directory structure containing slots like the following:

app
└── parallel-routes
    ├── @teams
    │   ├── settings
    │   │    └── page.tsx
    │   └── default.tsx

    ├── @analytics
    │   ├── default.tsx
    │   └── page.tsx
    ├── default.tsx
    ├── layout.tsx
    └── page.tsx

The pages for the teams, analytics, and children slots can be received and displayed in layout.tsx as follows:

layout.tsx
import "../globals.css";
import Link from "next/link";
import HardNavigationButton from "./HardNavigationButton";

export default function Layout({
  teams,
  analytics,
  children,
}: Readonly<{
  teams: React.ReactNode;
  analytics: React.ReactNode;
  children: React.ReactNode;
}>) {
  return (
    <>
        <Link href="/parallel-routes">
          <button className="m-4 bg-blue-400">soft navigete to root</button>
        </Link>
        <br />
        <Link href="/parallel-routes/settings">
          <button className="m-4 bg-blue-400">soft navigate to settings</button>
        </Link>
        <br />
        <HardNavigationButton />

        <section className="m-4">{children}</section>
        <section className="m-4">{teams}</section>
        <section className="m-4">{analytics}</section>
    </>
  );
}



Let's check the behavior of Parallel Routes.
To distinguish whether the content of a page or the content of default.tsx is being displayed in each slot, let's verify with code that has the same directory structure as the previous example.
https://github.com/axoloto210/zenn-article/tree/main/nextgram-modal

In the GIF below, after transitioning from /parallel-routes to /parallel-routes/settings via soft navigation, it transitions back to /parallel-routes again.





When accessing /parallel-routes, layout.tsx receives children, teams, and analytics. However, since there is no page corresponding to /parallel-routes in the teams slot, the content of @teams/default.tsx is displayed.
The page content for both children and analytics is displayed (purple sections).

At this point, when soft navigation to /parallel-routes/settings occurs, the content of @teams/settings/page.tsx is displayed for the teams slot, but even for the analytics and children slots, which are supposed to have no corresponding page, the content of default.tsx is not shown; instead, the content that was displayed at /parallel-routes continues to be displayed.

For @analytics and @children, because Next.js tracks and understands as an active state that content was displayed before the transition via soft navigation, it can decide to continue displaying that content after the transition.
When soft navigation back to /parallel-routes occurs, unlike the initial display, the content of @teams/settings/page.tsx continues to be displayed instead of @teams/default.tsx.




Now, if you perform a browser reload in this state, the content of @teams/default.tsx will be displayed again for the teams slot.
In hard navigation, Next.js cannot track the active state and cannot determine what to display, so the default content is shown instead.

Even when a browser reload is performed at /parallel-routes/settings, the slot that does not have a page corresponding to the URL will display the content of its corresponding default.tsx.

Intercepting Routes

While Parallel Routes allow multiple pages to be rendered simultaneously, Intercepting Routes allow you to intercept the rendering of another page at the same route (URL) and render an alternative page instead.
The condition for an intercept to occur is that the corresponding page is displayed via soft navigation.
https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

How to Define Intercepting Routes

Intercepting Routes are defined by using directory names like (.)folder.
You can specify which route's page to intercept, similar to relative paths like ../:

  • (.) matches segments on the same level
  • (..) matches segments one level above
  • (..)(..) matches segments two levels above
  • (...) matches segments from the root app directory

This notation also does not affect the URL.
While (..) specifies one level above, it does not refer to the hierarchy based on the file structure, but rather the hierarchy in terms of route segments (parts of the URL separated by /).


In the following file structure, since the slot @modal is not a route segment, the (..) in (..)photos refers to the same level as feed.
Note that in terms of the actual file structure, the feed directory is two levels above (..)photos.

.
└── app
    ├── feed
    │   └── @modal
    │       └── (..)photos
    └── photos

NextGram Modal Implementation

From here, we will look at how the modal is implemented in NextGram.

Directory Structure

NextGram achieves modal display with the following directory structure that combines Parallel Routes and Intercepting Routes.
Through this combination, a modal is displayed during soft navigation, but when accessing from a shared URL or through a hard navigation such as a browser reload, the modal is not shown, and instead, the content that was supposed to be inside the modal is displayed directly.

.
└── app
    ├── @modal
    │   ├── (.)photos
    │   │   └── [id]
    │   │       ├── modal.tsx
    │   │       └── page.tsx
    │   └── default.tsx
    ├── photos
    │   └── [id]
    │       └── page.tsx
    ├── default.tsx
    ├── layout.tsx
    └── page.tsx

Both Parallel Routes and Intercepting Routes are used together in @modal/(.)photos.
When a soft navigation occurs to a path like /photos/1, the interception by (.)photos/[id] takes effect. As a result, instead of rendering /photos/[id]/page.tsx, the content from @modal/(.)photos/[id]/page.tsx is displayed within the modal.

On the other hand, during hard navigation, interception does not occur, and the content from photos/[id]/page.tsx is displayed.

This behavior makes it possible to share the content within the modal (corresponding to photos/[id]/page.tsx) via URL and prevents the modal content from disappearing upon a browser reload (to achieve this, you need to ensure the display content in the modal slot and the children slot is consistent).

The layout.tsx at the root of NextGram includes a div element for displaying the modal. It is implemented by using React's createPortal to transfer the JSX elements for the modal created elsewhere into this div element.

layout.tsx
export default function RootLayout(props: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {props.children}
        {props.modal}
        <div id="modal-root" />
      </body>
    </html>
  );
}

createPortal

By using createPortal, you can render child elements (children) into a different part of the DOM.

createPortal(children, domNode, key?)
createPortal takes a child element children to be rendered and a target DOM node domNode as arguments, and returns a React node that renders the children under the domNode.
The actual element passed as domNode is typically obtained using document.getElementById().
This element must have already been rendered; if it is being updated, the portal will be recreated.

Regarding elements transferred with createPortal, it is important to note that event propagation follows the React tree structure rather than the DOM tree structure. createPortal only changes the physical position of the child elements received as arguments.

From the name createPortal, you can think of it as creating a portal to "warp" JSX elements.

https://ja.react.dev/reference/react-dom/createPortal

@modal/(.)photos/[id]/modal.tsx
'use client';

import { type ElementRef, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const dialogRef = useRef<ElementRef<'dialog'>>(null);

  useEffect(() => {
    if (!dialogRef.current?.open) {
      dialogRef.current?.showModal();
    }
  }, []);

  function onDismiss() {
    router.back();
  }

  return createPortal(
    <div className="modal-backdrop">
      <dialog ref={dialogRef} className="modal" onClose={onDismiss}>
        {children}
        <button onClick={onDismiss} className="close-button" />
      </dialog>
    </div>,
    document.getElementById('modal-root')!
  );
}

NextGram's Modal component is implemented using <dialog>.
While showModal() is used to open the dialog, in this implementation, the modal is opened by the logic within useEffect after the component is rendered.

As for closing the modal, by taking advantage of the fact that the modal display can be managed via the URL, simply returning to the previous page with router.back() is sufficient.


By wrapping the elements you want to display with this <Modal> component, a concise and highly functional modal implementation is achieved.

GitHubで編集を提案

Discussion