0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js の Layout と Context で「機能単位の状態管理」をする話

Posted at

Next.js の Layout と Context で「機能単位の状態管理」をする話

この記事では、Next.js(App Router)で Layout と Context(Provider)を組み合わせて、機能単位で状態を共有する設計 について整理します。


全体像:何をしたいのか?

やりたいことはシンプルです。

  • 本ページ機能には複数ステップの画面がある(例:Step1〜Step3)
  • その全ステップで 共通のキャンペーン状態(campaignId / isApplied) を使いたい
  • さらに、どのステップからでもキャンペーンを 適用・変更 できるようにしたい

これを実現するために:

  1. layout.tsx に Provider(Context)を仕込む
  2. useCampaign() という Hook で配下のコンポーネントから状態を読む・更新する

という構成を取ります。


Layout:機能全体の「枠」と Provider の入口

まず、カスタムボックス機能用の layout です。

import { ReactNode } from 'react';

import { CampaignProvider, ExampleDataProvider } from '@/consumer/features/example';

interface ExampleLayoutProps {
  children: ReactNode;
  params: Promise<{ example_setting_id: string }>;
}

/**
 * 全体のLayout
 * @description
 * - CampaignProvider: キャンペーン適用状態を管理
 * - ExampleDataProvider: 全ステップのデータを提供
 */
export default async function ExampleLayout({ children, params }: ExampleLayoutProps) {
  const { example_setting_id: exampleSettingId } = await params;

  return (
    <CampaignProvider>
      <ExampleDataProvider exampleSettingId={exampleSettingId}>
        {children}
      </ExamplexDataProvider>
    </CampaignProvider>
  );
}

ポイント

ExampleLayout は、/examples/[example_id]/** 配下のページ共通のレイアウト。

ここで CampaignProviderExampleDataProvider を噛ませることで、
Example 機能全体 でキャンペーン状態・商品データを共有している。

children には配下の page.tsx やその中のコンポーネントがすべて入る。

図にするとこんなイメージ:

ExampleLayout
└─ CampaignProvider(キャンペーン状態)
   └─ ExampleDataProvider(Example の商品データ)
      └─ children(この配下のページ / コンポーネント全部)
          ├─ Step1Page
          ├─ Step2Page
          ├─ ConfirmPage
          └─ ...

Page コンポーネントは「ルーティングの薄いラッパー」

page.tsx はこんな実装になっている想定です。

import ExampleSettingPage from '@/consumer/page-components/examples/[example_id]';

interface PageProps {
  params: Promise<{
    example_id: string;
  }>;
}

export default async function Page({ params }: PageProps) {
  const { example_id } = await params;
  return <ExampleSettingPage exampleId={example_id} />;
}

役割

Page コンポーネントは Next.js によるルーティングの窓口。

URL の [example_id] から params を受け取り、
必要な値だけを ExampleSettingPage に渡している。

実際の画面・ロジックは ExampleSettingPage 側に「丸投げ」している構成。


この分け方のメリット:

ExampleSettingPage は「ルーティングに依存しないただの React コンポーネント」としても再利用できる。

App Router 特有の処理(params, generateMetadata など)を page.tsx に閉じ込められる。

CampaignProvider:キャンペーン状態を管理する Context

次に、キャンペーン状態を管理する Provider の実装です。

'use client';

import { ReactNode, useState } from 'react';

import { generateValueContext } from '@/shared/lib/context';

type CampaignContextValue = {
  campaignId: string | null;
  setCampaignId: (id: string | null) => void;
  isApplied: boolean;
};

const { Provider: CampaignContextProvider, useValue: useCampaignContext } =
  generateValueContext<CampaignContextValue>();

interface CampaignProviderProps {
  children: ReactNode;
}

/**
 * キャンペーン適用状態を管理するProvider
 * @description
 * Layout層で使用し、campaign_idをグローバルに管理します。
 * Example 機能全体でキャンペーン適用状態を共有します。
 */
export function CampaignProvider({ children }: CampaignProviderProps) {
  const [campaignId, setCampaignId] = useState<string | null>(null);

  const value: CampaignContextValue = {
    campaignId,
    setCampaignId,
    isApplied: campaignId !== null,
  };

  return <CampaignContextProvider value={value}>{children}</CampaignContextProvider>;
}

/**
 * キャンペーン状態を取得するHook
 * @throws Provider外で使用された場合にエラー
 * @returns キャンペーン適用状態とセッター
 */
export function useCampaign() {
  const context = useCampaignContext();
  if (!context) {
    throw new Error('useCampaign must be used within CampaignProvider');
  }
  return context;
}
Context に入っている値と意図
type CampaignContextValue = {
  campaignId: string | null;                  // 現在適用中のキャンペーンID
  setCampaignId: (id: string | null) => void; // キャンペーンIDを更新する関数
  isApplied: boolean;                         // キャンペーンが適用されているか(派生値)
};

なぜ setCampaignId まで入れているのか?

campaignId / isApplied を「読むだけ」なら、getter だけでも良さそうですが、
この機能では、ステップ1でクーポン入力、ステップ2でキャンペーン変更、確認画面でキャンペーン解除
など、複数の画面からキャンペーン状態を変更したいという要件があります。

そのため、
「状態(campaignId)だけでなく、
その状態を更新するための関数(setCampaignId)も Context で公開する」ことで、
どのステップ・コンポーネントからでも useCampaign() を呼べば
campaignId を参照できる、setCampaignId() で更新できるという構造を作れます

isApplied 状態ではなく派生値
isApplied: campaignId !== null,

isApplied は別途 useState しているわけではありません。

campaignId が null かどうかから計算された 派生情報 です。

コンポーネント側で毎回 campaignId !== null と書かなくて済むように、
「キャンペーン適用中か?」という boolean を Context 側で整えています。

generateValueContext:値コンテキストを簡単に作るヘルパー

このコードでは、汎用的な Context 生成ヘルパーを使っています。

/**
 * 値のバケツリレーを不要にするだけのContextを生成する
 */
export const generateValueContext = <T,>() => {
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  const valueContext = createContext<T>(null!);

  const Provider = ({ children, value }: { children: React.ReactNode; value: T }) => {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return <valueContext.Provider value={value}>{children}</valueContext.Provider>;
  };

  const useValue = () => useContext(valueContext);
  return { Provider, useValue };
};

やっていることはシンプルで、

createContext

それに対応する Provider

それを読むための useValue Hook

を、ジェネリクスでまとめて生成しているだけです。

他の箇所でproviderを使いたい時に汎用的になります。

Layout に Provider を置くことの意味

Layout の役割(App Router)

・/app/**/layout.tsx は そのディレクトリ配下のページ共通のラッパー。

Layout はページ遷移しても再マウントされにくく、
その配下の children だけが切り替わる

・なぜ Layout に CampaignProvider を置くのか?

Example 機能は 複数ステップの画面 で構成される。
どのステップからでも同じキャンペーン状態を使いたい。
さらに、ステップ間を移動しても状態を保持したい。

→ つまりExample「機能単位」で グローバルっぽい状態 を持ちたいのです

そのために:

ExampleLayoutCampaignProvider を置くことで、
/examples/[example_id]/** 配下のすべてのページで useCampaign() が使える
ステップをまたいでも campaignId の状態が保持される
という素晴らしいことになります

図解
app/examples/[example_id]/layout.tsx
  └─ ExampleLayout
      └─ CampaignProvider(Client:stateとContextを管理)
          └─ ExampleDataProvider
              └─ app/examples/[example_id]/page.tsx
                  └─ Page(paramsからidを取り出して…)
                      └─ ExampleSettingPage ← 画面・ロジック本体
                          └─ useCampaign() でContext利用

設計のポイントまとめ

Context に何を入れるか?

「状態(campaignId)」

「それを更新する関数(setCampaignId)」

「派生値(isApplied)」

をセットで持たせることで、読み取り & 更新の両方を useCampaign() に集約できる。
キャンペーン状態の出入口を一箇所にまとめられる。

Layout に Provider を置く理由

Example のように 機能単位で状態を共有したい とき、その機能の layout.tsx に Provider を仕込むのが自然。
ステップ間遷移でも Provider が生き残るため、状態が保持される。

Page コンポーネントの役割

ルーティングから params を受け取り、

実際の UI コンポーネント(ExampleSettingPage)に必要な props を渡す
薄いラッパー にとどめる。

generateValueContext のメリット

「値を一つ流すだけの Context」を簡単に作れるユーティリティ。

ボイラープレートを減らしつつ、型安全な Context 設計ができる。

おわりに

Layout は「画面の枠」だけでなく、**「機能単位の状態のスコープ」**としても使える。

Provider を Layout に置くことで、
機能ごとの状態をきれいに閉じ込めつつ、
ステップ遷移やページまたぎの UX を崩さずに状態管理ができる。

Example のようなケース以外にも、
マルチステップフォーム
管理画面の特定モジュール
ショッピングカート
フロー型のオンボーディング

など、「その機能の中だけで横断的に共有したい状態」があれば、
同じパターンを適用できるはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?