Next.js の Layout と Context で「機能単位の状態管理」をする話
この記事では、Next.js(App Router)で Layout と Context(Provider)を組み合わせて、機能単位で状態を共有する設計 について整理します。
全体像:何をしたいのか?
やりたいことはシンプルです。
- 本ページ機能には複数ステップの画面がある(例:Step1〜Step3)
- その全ステップで 共通のキャンペーン状態(campaignId / isApplied) を使いたい
- さらに、どのステップからでもキャンペーンを 適用・変更 できるようにしたい
これを実現するために:
-
layout.tsxに Provider(Context)を仕込む -
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]/** 配下のページ共通のレイアウト。
ここで CampaignProvider と ExampleDataProvider を噛ませることで、
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「機能単位」で グローバルっぽい状態 を持ちたいのです
そのために:
ExampleLayout に CampaignProvider を置くことで、
/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 のようなケース以外にも、
マルチステップフォーム
管理画面の特定モジュール
ショッピングカート
フロー型のオンボーディング
など、「その機能の中だけで横断的に共有したい状態」があれば、
同じパターンを適用できるはずです。