最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - NextJS App Routing - modal that persists on refresh - Stack Overflow

programmeradmin1浏览0评论

I've looked into the Modal method described on this page in the NextJS docs , and at the answers for Stack Overflow questions like this one, but in all of these examples, when the page is refreshed the modal just appears as its own route, without the original content behind it. Is there a way I can set up the folder structure in such a way that:

  • There is a page, e.g. /lorem, with a list and a button on it
  • A user clicks the button
  • The URL changes to e.g. /lorem/new, and a modal appears above the list
  • If the user refreshes the page, the modal remains, but so does the list behind it.

I've have managed to achieve this using layout.tsx to render the list, a page.tsx that returns null to handle the /lorem route, and a nested new/page.tsx to render the modal. However this brings about other issues relating to access to searchParams etc, and just generally feels very hacky, to have a layout acting as a page and a page returning null. So a more 'proper' way to do this would be ideal.

I've looked into the Modal method described on this page in the NextJS docs , and at the answers for Stack Overflow questions like this one, but in all of these examples, when the page is refreshed the modal just appears as its own route, without the original content behind it. Is there a way I can set up the folder structure in such a way that:

  • There is a page, e.g. /lorem, with a list and a button on it
  • A user clicks the button
  • The URL changes to e.g. /lorem/new, and a modal appears above the list
  • If the user refreshes the page, the modal remains, but so does the list behind it.

I've have managed to achieve this using layout.tsx to render the list, a page.tsx that returns null to handle the /lorem route, and a nested new/page.tsx to render the modal. However this brings about other issues relating to access to searchParams etc, and just generally feels very hacky, to have a layout acting as a page and a page returning null. So a more 'proper' way to do this would be ideal.

Share Improve this question asked Feb 25, 2024 at 12:10 rwacarterrwacarter 2,0043 gold badges16 silver badges29 bronze badges
Add a ment  | 

2 Answers 2

Reset to default 11 +500

You are trying to do a mix of intercepted and parallel routes and I think that's where the confusion lies.

Quick demo of my solution:

Here is my file tree:

├── app
│   └── lorem
│       ├── @modal
│       │   ├── (.)new        <-- Intercepted Route (shown when you click the link)
│       │   │   └── page.tsx
│       │   │ 
│       │   ├── new           <-- Parallel Route (shown when /lorem/new refreshed)
│       │   │   └── page.tsx
│       │   │ 
│       │   ├── default.ts    <-- Default Behaviour (hides modal on child routes that do not have parallel routes)
│       │   └── page.ts       <-- Parallel Route (hides modal on /lorem)
│       │   
│       ├── new
│       │   └── page.tsx
│       │   
│       ├── layout.tsx
│       └── page.tsx
│ 
└── ponents
    ├── list-content.tsx
    ├── modal-button.tsx
    ├── modal-window.tsx
    └── new-item-modal.tsx
  1. Add a layout.tsx to your lorem directory and add the following code:
/** 
 * @/app/lorem/layout.tsx
 */

import Link from 'next/link';
import type { FC, ReactNode } from 'react'

interface LayoutProps {
  modal: ReactNode;
  children: ReactNode;
}

const Layout: FC<LayoutProps> = ({ modal, children,}) => {
  return (
    <div className="max-w-screen-lg mx-auto pt-10 space-y-10">
      <nav className="text-center">
        <Link href={"/lorem/new"}>Open modal</Link>
      </nav>
      <main className="w-full">
        {modal}
        {children}
      </main>
    </div>
  );
}

export default Layout
  1. Your /lorem and /lorem/new pages will have the exact same content: the list you wish to display.
/**
 * @/app/lorem/page.tsx
 */
import ListContent from "@/ponents/list-content";

export default function RootPage() {
  return <ListContent />
}
/**
 * @/app/lorem/new/page.tsx
 */
import ListContent from "@/ponents/list-content";

export default function NewPage() {
  return <ListContent />
}

Here's what my <ListContent /> looks like btw:

/**
 * @/ponents/list-content.tsx
 */

import type { FC } from 'react'

const ListContent: FC = () => {
  return (
    <div className="text-center">
      <ul>
        <li>Item 1</li>
        <li>Item 2</li>
        <li>Item 3</li>
      </ul>
    </div>
  )
}

export default ListContent

Quick note about the <ListContent /> ponent: If you refresh the page on /lorem/new and then click the close button on the modal, the <ListContent /> will re-render. If you don't want the data for <ListContent /> to refetch or enter a loading state upon navigation from the non-intercepted version of /lorem/new, I would remend adding some sort of caching mechanism that checks if data is stale or not before refetching.

Otherwise, you may have to resort to using the method you've described where you add the <ListContent/> ponent to app/lorem/layout.tsx and then returning null for both @/app/lorem/page.tsx and @/app/lorem/new/page.tsx. In the interest of reducing jank, I would not remend this and opt for caching instead.

  1. Create a @modal subdirectory within app/lorem.

  2. Add a default.ts file (documentation here). Returning null ensures that the modal is closed when the active state or slot content is unknown (saves you the step of defining every single parallel route as returning null for every child route under /lorem). Without this file, a 404 error may occur when the slot content is undefined.

/**
 * @/app/lorem/@modal/default.ts
 */

export default function DefaultNewModal() {
  console.log("I am the default modal view for when the layout state is unknown")
  return null
}
  1. Next, we'll create a page.ts in the root of the @modal folder. This will be the parallel route for the modal slot when you navigate to /lorem. In this case, we don't want to show the modal slot, so we'll return null.
/**
 * @/app/lorem/@modal/page.ts
 */

export default function RootParallelRoute() {
  console.log("I am the modal slot on /lorem")
  return null
}
  1. Before continuing with the intercepted route, let's create the modal ponents. I made a reusable <ModalWindow /> wrapper with an optional route prop which will allow me to use different behaviours for the close button depending on the context in which it is shown (this will make more sense in a moment, I promise)
/**
 * @/ponents/modal-wrapper.tsx
 */

import type { FC, ReactNode } from 'react'
import ModalButton from "@/ponents/modal-button";


interface ModalWindowProps {
  children: ReactNode
  route?: string
}

const ModalWrapper: FC<ModalWindowProps> = ({ children, route }) => {
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center mx-auto pointer-events-none">
      <div className="fixed inset-0 bg-black bg-opacity-50" />
      <div className="relative z-10 pointer-events-auto">
        {children}
        <ModalButton route={route} />
      </div>
    </div>
  )
}

export default ModalWrapper

Here is the <ModalButton />

/**
 * @/ponents/modal-button.tsx
 *
 * NOTE: this is the only client ponent in the app
 */

'use client'

import type { FC } from 'react'
import { useRouter } from "next/navigation";
import Link from "next/link";

interface ModalButtonProps {
  route?: string
}

const ModalButton: FC<ModalButtonProps> = ({ route }) => {
  const router = useRouter()

  const handleClose = () => {
    router.back()
  }

  if (route) {
      return <Link href={route}>Close Modal</Link>
  }

  return <button onClick={handleClose}>Close Modal</button>
}

export default ModalButton

And finally, let's create our <NewItemModal /> ponent.

/**
 * @/ponents/new-item-modal.tsx
 */

import { get as testEndpoint } from "@/app/api/test/route"
import ModalWrapper from "@/ponents/modal-wrapper"

interface NewItemModalProps {
  route?: string
}

export default async function NewItemModal({ route }: NewItemModalProps) {
  const data = await testEndpoint().json()

  console.log("Modal fetched data:", data) // Returns the "Hello, World!" message from the test API route
  
  return (
    <ModalWrapper route={route}>
      <div className="container mx-auto">
        <div className="rounded-md m-4 bg-sky-600 p-4">
          <p>Behold! A modal window :-)</p>
        </div>
      </div>
    </ModalWrapper>
  )
}

Quick note about the route prop: If we only relied on the history stack of the user's browser for the "Close" button (via router.back()), the user will end up being stuck if they are visiting /lorem/new via manually inputting the URL into their address bar (ie, opening a new tab). This is why we have the route prop being used in the parallel route so that we can redirect them to the root /lorem page where, if they open the modal again, they'll see the intercepted route version of the modal that has the router.back() behaviour from that point on.

  1. Next, we're going to add the modal to two places. The first one will be the intercepted route, which will show the modal when you click the "Open modal" button from the /lorem path.

Do this by creating a directory named (.)new under @modal and creating a page.tsx file for it.

/**
 * @/app/lorem/@modal/(.)new/page.tsx
 */

import NewItemModal from "@/ponents/new-item-modal";

/**
 * Shows when the /new route is intercepted
 */
export default function InterceptedPage() {
  console.log("I am the modal that is shown during interception")
  return <NewItemModal />
}
  1. Lastly, we'll create a new directory at app/lorem/@modal/new which will serve as the parallel route for the @modal slot when viewing /lorem/new after a refresh.
/**
 * @/app/lorem/@modal/new/page.tsx
 */

import NewItemModal from "@/ponents/new-item-modal"

/**
 * Shown on /lorem/new on fresh page load (refresh, open in new tab, etc.)
 */
export default function ParallelRoutePage() {
  console.log("I am the modal that is shown when the page is refreshed")
  return <NewItemModal route={'/lorem'} />
}

Using this method, you can have other modals use the @modal slot to not only persist the modals, but also be able to link to them like with /lorem/new.

For instance, adding a modal for the purpose of editing list items would result in a directory tree like this:

├── app
│   └── lorem
│       ├── @modal
│       │   ├── (.)edit
│       │   │   └── page.tsx
│       │   │ 
│       │   ├── (.)new        
│       │   │   └── page.tsx
│       │   │ 
│       │   ├── edit           
│       │   │   └── page.tsx
│       │   │ 
│       │   ├── new           
│       │   │   └── page.tsx
│       │   │ 
│       │   ├── default.ts
│       │   └── page.ts      
│       │   
│       ├── edit
│       │   └── page.tsx
│       │
│       ├── new
│       │   └── page.tsx
│       │   
│       ├── layout.tsx
│       └── page.tsx
│ 
└── ponents
    ├── edit-item-modal.tsx
    ├── list-content.tsx
    ├── modal-button.tsx
    ├── modal-window.tsx
    └── new-item-modal.tsx

While there is a teensy bit of repetition/redundancy using this method, it offers the opportunity to define different behaviour (like transitions) as well as different content for the various contexts in which your modal will appear.

For instance, let's say you want the <EditItemModal /> to only show if the route is intercepted:

  • Keep the intercepted route (/lorem/@modal/(.)edit/page.tsx)
  • Delete the parallel route (/lorem/@modal/edit/page.tsx)
  • Add your edit form to the edit page (/lorem/edit/page.tsx)

Hope this gets you headed in the right direction!

This may also not be the proper way to do that, but it works without a layout acting as a page and a page returning null:

Create a directory named [...slug] for example, containing a page.js file (should of course also work with page.tsx). So the directory structure would look something like this:

The naming ensures that all paths and subpaths are served by that page.js file. In that file you can get the entered path and decide which content to show or to hide. If you refresh the website at /lorem/new, it still shows the regular content and the modal. You'll probably want to add style according to your preferences.

src/app/[...slug]/page.js:

"use client";

import { useRouter } from "next/navigation";

export default function Page({ params }) {
    const router = useRouter();
    return (
        params.slug[0] === "lorem" && (
            <main>
                <ul>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                    <li>regular list content</li>
                </ul>
                {params.slug.length === 2 && params.slug[1] === "new" ? (
                    <>
                        <button onClick={() => router.push("/lorem")}>close modal</button>
                        <dialog style={{ display: "block" }}>This is only visible in /lorem/new</dialog>
                    </>
                ) : (
                    <button onClick={() => router.push("/lorem/new")}>show modal</button>
                )}
            </main>
        )
    );
}

It has to be considered, that the information, that the modal is shown, must be passed to the server somehow. In this solution that happens with the new part of the URL. But you could also use searchParams for that for example, then you wouldn't have to use a [...slug] directory.

发布评论

评论列表(0)

  1. 暂无评论