Closed23

Next.js の Parallel Routes と Intercepting Routes を理解したい

nbstshnbstsh

project setup

とりあえず、create-next-app で空の project 作る。

npx create-next-app@latest

nbstshnbstsh

Parallel Routes & Intercepting Routes の Modal example

公式 Doc に沿って実装してみる。

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

nbstshnbstsh

Login Page 作る

app/login/page.tsx 用意。

app/login/page.tsx
const LoginPage = () => {
  return (
    <div>
      <h1 className='text-4xl font-bold'>Login</h1>
    </div>
  );
};

export default LoginPage;

/login で login page が表示される。

nbstshnbstsh

@auth slot 作成

app/@auth/default.tsx を用意。

公式 Doc だと null を return しているが、一旦 UI に表示してみたいのでこうする↓

app/@auth/default.tsx
const AuthSlotDefault = () => {
  return (
    <div>
      <h1 className='text-4xl font-bold'>@auth slot default</h1>
    </div>
  );
};

export default AuthSlotDefault;
nbstshnbstsh

layout に @auth slot 出してみる

app/layout.tsx
export default function RootLayout({
  children,
+  auth,
}: Readonly<{
  children: React.ReactNode;
+  auth: React.ReactNode;
}>) {
  return (
    <html lang='en'>
      <body className={inter.className}>
        {children}

+        <div className='mt-5 border p-5'>
+          <div>Auth Slot comes here↓↓↓↓</div>
+          <div>{auth}</div>
+        </div>
      </body>
    </html>
  );
}

/ にアクセス。
@auth slot の default.tsx の内容出てきてないぞ...

nbstshnbstsh

一度 dev server 止めて、再度スタートしたら出た↓

nbstshnbstsh

/login を開く

  • app/login/page.tsx
  • app/@auth/defafult.tsx

どちらの内容も表示されていることがわかる。

nbstshnbstsh

Parallel Routes 一旦まとめ

  • Parallel Routes は複数の "Page 領域" 的なものを一つの Layout に共存させるようなもの。
  • Parallel Routes に現在の path に match する route の page.tsx が存在しない場合、default.tsx が利用される。
nbstshnbstsh

Intercepting Routes 動作確認

公式 Doc にはないが、動作を確認したいので root に /login の intercepting route を作ってみる。

app/(.)login/page.tsx

app/(.)login/page.tsx
const InterceptingLogin = () => {
  return (
    <div>
      <h1 className='text-4xl font-bold'>Intercepting Login</h1>
    </div>
  );
};

export default InterceptingLogin;

Login page への Link を root layout に追加する。

app/layout.tsx
export default function RootLayout({
  children,
  auth,
}: Readonly<{
  children: React.ReactNode;
  auth: React.ReactNode;
}>) {
  return (
    <html lang='en'>
      <body className={inter.className}>
+        <Header />

        {children}

        <div className='mt-5 border p-5'>
          <div>Auth Slot comes here↓↓↓↓</div>
          <div>{auth}</div>
        </div>
      </body>
    </html>
  );
}

Header component

shadcn の NavigationMenu を利用。

app/_layout/header.tsx
'use client';

import {
  NavigationMenu,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import Link from 'next/link';

export const Header = () => {
  return (
    <header className='p-2'>
      <NavigationMenu>
        <NavigationMenuList>
          <NavigationMenuItem>
            <Link href='/' legacyBehavior passHref>
              <NavigationMenuLink className={navigationMenuTriggerStyle()}>
                Home
              </NavigationMenuLink>
            </Link>
          </NavigationMenuItem>
          <NavigationMenuItem>
            <Link href='/login' legacyBehavior passHref>
              <NavigationMenuLink className={navigationMenuTriggerStyle()}>
                Login
              </NavigationMenuLink>
            </Link>
          </NavigationMenuItem>
        </NavigationMenuList>
      </NavigationMenu>
    </header>
  );
};

https://ui.shadcn.com/docs/components/navigation-menu

Home => Login 移動

この状態で、Header の navitation link から /login へ画面遷移すると app/(.)login/page.tsx が表示される↓

Login リロード

/login で browser をリロードすると app/login/page.tsx が表示される↓

nbstshnbstsh

@auth slot に intercepting route 作成

ここから公式 Doc 通りに、@auth slot に login page の Intercepting Route を作成する。

app/@auth/(.)login/page.tsx を作成。

app/@auth/(.)login/page.tsx
const AuthInterceptingLogin = () => {
  return (
    <div className='text-red-600 text-lg font-bold'>
      Intercepting Login @auth slot
    </div>
  );
};

export default AuthInterceptingLogin;

1. Home / 表示

2. Home / => Login /login に移動

3. Login /login リロード

一旦整理

  • Soft Navigation で /login へ移動すると、@auth slot の login page intercepting route (app/@auth/(.)login/page.tsx ) が表示される。
  • 上記以外は、@auth slot の default.tsx (app/@auth/default.tsx) が表示される。
nbstshnbstsh

Parallel Routes と Intercepting Routes を組み合わせて作る Login Modal

ここまでで、Parallel Routes と Intercepting Routes についてだいぶ理解できた。

これらを組み合わせれば、これ↓ができるわけだな。

  • Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する
  • 直接 Login page を開いた時は Login 専用の Page を表示する
nbstshnbstsh

やることを整理

  • Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する
  • 直接 Login page を開いた時は Login 専用の Page を表示する

この体験を実現するために必要な実装項目を洗い出す。

直接 Login page を開いた時は Login 専用の Page を表示する

  • Login Page を用意する

Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する

  • @auth slot はデフォで何も表示しない
  • @auth slot に Login Page の intercepting route を用意して、Modal で Login Form を表示
nbstshnbstsh

いらないもの消しとく

Root Layout お掃除
app/layout.tsx
export default function RootLayout({
  children,
  auth,
}: Readonly<{
  children: React.ReactNode;
  auth: React.ReactNode;
}>) {
  return (
    <html lang='en'>
      <body className={inter.className}>
        <Header />

        {children}

        {auth}
      </body>
    </html>
  );
}

nbstshnbstsh

直接 Login page を開いた時は Login 専用の Page を表示する

チャチャっと Login Page の UI を作る。(@auth slot は後で消す。)

Login Page components
app/login/__form/login-form.tsx
'use client';

import { Button } from '@/components/ui/button';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useForm } from 'react-hook-form';

export const LoginForm = () => {
  const form = useForm();

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(console.log)} className='grid gap-y-3'>
        <FormField
          control={form.control}
          name='username'
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder='shadcn' {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name='password'
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input placeholder='********' {...field} type='password' />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type='submit' className='mt-6'>
          Submit
        </Button>
      </form>
    </Form>
  );
};
app/login/page.tsx
import { LoginForm } from './_form/login-form';

const LoginPage = () => {
  return (
    <div className='container relative h-[700px] grid place-items-center'>
      <div className='mx-auto flex w-[400px] flex-col justify-center space-y-6'>
        <div className='grid gap-y-2 text-center justify-items-center'>
          <h1 className='text-2xl font-semibold tracking-tight'>
            Welcome Back!
          </h1>
          <p className='text-sm text-muted-foreground'>
            Enter your email and password below to login.
          </p>
        </div>
        <LoginForm />
      </div>
    </div>
  );
};

export default LoginPage;
nbstshnbstsh

Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する

@auth slot はデフォで何も表示しない

app/@auth/default.tsx を修正して、何も表示しないようにする。

app/@auth/default.tsx
const AuthSlotDefault = () => {
  return null;
};

export default AuthSlotDefault;

Login page から "@auth slot default" が消えたこと確認。

@auth slot に Login Page の intercepting route を用意して、Modal で Login Form を表示

shadcn UI の Dialog をデフォで開く状態にして LoginForm component を表示。

app/@auth/(.)login/page.tsx
'use client';

import { LoginForm } from '@/app/login/_form/login-form';
import { Dialog, DialogContent } from '@/components/ui/dialog';

const AuthInterceptingLogin = () => {
  return (
    <Dialog open>
      <DialogContent>
        <h2 className='text-2xl font-semibold tracking-tight'>Login</h2>
        <LoginForm />
      </DialogContent>
    </Dialog>
  );
};

export default AuthInterceptingLogin;

HOME (/) => Login Page (/login) に移動すると Modal が表示される↓

nbstshnbstsh

ブラウザの back/forward navigation による Modal の開閉確認

@auth slot の intercepting route により、ブラウザの back/forward navigation による Modal の開閉ができるようになった↓

nbstshnbstsh

クリックして Modal を閉じれるようにする

現状、Modal の閉じるボタンや overlay をクリックしても閉じれないので、ちゃんと閉じれるように対応する。

Modal を閉じる際に、router.back() するようにしてあげる↓

app/@auth/(.)login/page.tsx
const AuthInterceptingLogin = () => {
  const router = useRouter();

  return (
    <Dialog
      open
+      onOpenChange={(isOpen) => {
+        if (!isOpen) {
+          router.back();
+        }
+      }}
    >
      <DialogContent>
        <h2 className='text-2xl font-semibold tracking-tight'>Login</h2>
        <LoginForm />
      </DialogContent>
    </Dialog>
  );
};
nbstshnbstsh

別のページへ遷移したときに Modal 閉じない問題

router.back() では Modal 閉じれるが、Soft Navigation (router.push('/') のような形) でHome 画面へ遷移しようとすると、Modal が開きっぱなしになる。

試しに、Login Modal の中に Home への Link を置いて検証。

//...
        <Button asChild variant={'link'}>
          <Link href='/'>Navigate to Home</Link>
        </Button>
//...

"Navigate to Home" を押しても、Modal は開きっぱし↓

原因

Soft Navigation: During client-side navigation, Next.js will perform a partial render, changing the subpage within the slot, while maintaining the other slot's active subpages, even if they don't match the current URL.

parallel route では、すでにある slot の page が表示されている状態で Soft Navigation が生じ、その slot に該当する route が存在しない場合は、現状表示されている page がそのまま生き残る挙動になるらしい。

https://nextjs.org/docs/app/building-your-application/routing/parallel-routes#active-state-and-navigation

対応

When using the Link component to navigate away from a page that shouldn't render the @auth slot anymore, we use a catch-all route that returns null.

@auth slot 内に catch-all route を作って対応する。
login page の intercepting route 以外では、@auth slot では何も表示しないよう null を返す catch-all route 作成。

公式 Doc の example では [...catchAll] としているが、これだと / の時が該当しないので optional catch all segment [[...catchAll] を使う。

app/@auth/[...catchAll]/page.tsx
export default function CatchAll() {
  return null;
}

これで Login Modal から別の route へ Soft Navigation した際も Modal が正しく閉じられるようになる。

nbstshnbstsh

最終動作確認

  • HOME => Login 遷移で Modal 表示
  • back navigation で Modal 閉じる
  • foward navigation で Modal 表示
  • Modal の "閉じるボタン" クリックで Modal 閉じる
  • Modal の overlay クリックで Modal 閉じる
  • Modal 内 Link から Home へ遷移して Modal 閉じる
  • Modal 開いた状態でリロードして Login Page を表示

nbstshnbstsh

Rarallel Routes と Intercepting Routes の Modal まとめ

Soft Navigation 時は Modal を開くが、Had Navigation 時は個別のページを表示したい

これを実現するためには、Parallel Route で Modal を表示するための slot を用意し、以下を実装すればOK

  • Modal を表示したい route の Intercepting Route を作成
    • "閉じる" フローで route.back() を実装
  • slot の default.js を用意 (何も表示しない)
  • slot の optional catch-all route を用意 (何も表示しない)
このスクラップは2024/02/25にクローズされました