© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsstate-management

Zustand Test の uiStore でタブ切り替えだけを切り出す

アクティブなタブの状態だけを抱える uiStore を独立させ、settings / profile / detail と疎結合に保つ最小設計を解説する。

難易度: 初級·更新: 2026-05-07·
zustand

対応バージョン

nextjs 15react 19zustand 5

前提環境

Zustand の create と selector の基本を理解していること

概要

Zustand Test の uiStore は アクティブなタブの ID だけを抱える小さな store です。settingsStore や profileStore などビジネスデータ系とは独立させており、リセットや subscribe 配線にも巻き込まれません。

なぜ分けるのかは要するに 3 点です。

  • UI の関心事をデータの関心事から切り離す。タブを切り替えたときに settings の値が動かないのが当たり前になる
  • 再レンダリング範囲を狭める。タブ切替で settingsStore を購読しているコンポーネントが再描画されない
  • 永続化やリセットの方針を独立させやすい。「タブだけは保持する」「データだけリセットする」が選べる

インストール

npm install zustand

最小実装

タブ ID を as const で union 型に閉じ込め、setTab で更新するだけの最小構成です。

// src/store/uiStore.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";

const TABS = ["form", "detail", "result"] as const;
export type Tab = (typeof TABS)[number];

const INITIAL_UI = { activeTab: "form" as Tab };

type UiState = {
  activeTab: Tab;
  setTab:    (tab: Tab) => void;
  reset:     () => void;
};

export const useUiStore = create<UiState>()(
  devtools(
    (set) => ({
      ...INITIAL_UI,
      setTab: (activeTab) => set({ activeTab },   false, "setTab"),
      reset:  ()           => set(INITIAL_UI,     true,  "reset"),
    }),
    { name: "UiStore" }
  )
);

as const で配列リテラルから union 型を導出することで、「タブの集合」と「型」を 1 か所で定義 できます。後でタブを増やしたければ配列に追加するだけで補完が効くようになります。

selector で activeTab だけ購読する

タブ切り替え本体は primitive な値だけを購読します。activeTab 以外を将来追加しても、この selector を使っている限り再描画範囲は最小 です。

// src/components/TabbedArea.tsx
"use client";
import { useUiStore } from "@/store/uiStore";
import { FormTab }    from "./tabs/FormTab";
import { DetailTab }  from "./tabs/DetailTab";
import { ResultTab }  from "./tabs/ResultTab";

export function TabbedArea() {
  const activeTab = useUiStore((s) => s.activeTab);

  return (
    <div>
      <TabBar />
      {activeTab === "form"   && <FormTab />}
      {activeTab === "detail" && <DetailTab />}
      {activeTab === "result" && <ResultTab />}
    </div>
  );
}

TabBar はボタン UI のみで、setTab を直接 store から取り出します。

// src/components/TabBar.tsx
"use client";
import { useUiStore, type Tab } from "@/store/uiStore";

const tabs: ReadonlyArray<{ id: Tab; label: string }> = [
  { id: "form",   label: "フォーム"   },
  { id: "detail", label: "詳細"       },
  { id: "result", label: "結果表示"   },
];

export function TabBar() {
  const activeTab = useUiStore((s) => s.activeTab);
  const setTab    = useUiStore((s) => s.setTab);

  return (
    <nav className="flex gap-2 border-b">
      {tabs.map((t) => (
        <button
          key={t.id}
          onClick={() => setTab(t.id)}
          aria-current={activeTab === t.id}
          className={activeTab === t.id ? "font-bold" : ""}
        >
          {t.label}
        </button>
      ))}
    </nav>
  );
}

activeTab と setTab を 個別の selector で取得することで、setTab の参照は不変なまま、activeTab の変化のみ TabBar の再描画を起こします。

他 store と疎結合に保つ

uiStore は他の store を import しない のが原則です。タブの切替時に何か副作用が必要なケース(例: 結果表示タブを開いたときに最新の確定値を再読込)があっても、それは useEffect の中で個別の store から getState() を呼ぶ形にして、uiStore 自身には書かないでおきます。

// src/components/tabs/ResultTab.tsx
"use client";
import { useEffect } from "react";
import { useUiStore }       from "@/store/uiStore";
import { useSettingsStore } from "@/store/settingsStore";

export function ResultTab() {
  const activeTab = useUiStore((s) => s.activeTab);

  useEffect(() => {
    if (activeTab !== "result") return;
    const snapshot = useSettingsStore.getState();
    // ...必要なら何か処理
  }, [activeTab]);

  return <div>{/* ... */}</div>;
}

これにより、タブの定義を uiStore 1 か所に閉じ込めたまま、副作用は呼び出し側のコンポーネントが責任を持ちます。

リセット時に「タブを戻す」かどうか

「全ストアをリセット」ボタンが uiStore.reset() も呼ぶかどうかは設計判断です。Playground では タブも初期化 する方を採用しています。

  • リセット → タブも form に戻す: 「最初の入力からやり直す」体験になりやすい
  • リセット → タブは維持する: 結果タブを見ながら値だけリセットしたいケースで便利

どちらでも良いですが、どちらにしたかをコードに明示 しておくと迷いません。Playground は前者なので useUiStore.getState().reset() を呼ぶ並びに含めています。

ポイント

  • タブ ID は as const の配列から union 型を導出して 集合と型を 1 か所 にする
  • activeTab と setTab は 個別の selector で取り出す。setTab の参照は不変なので props で渡しても安全
  • uiStore から他 store を import しない。副作用は呼び出し側の useEffect に置く
  • リセット時にタブも戻すかどうかは設計判断。選んだ方をコードで明示 する
  • 表示制御専用の小さな store を 1 つ作っておくだけで、データ系 store の再描画範囲を絞れる

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • Zustand 編 — 4 store 全体の役割分担
  • profileStore 編 — 同じく独立した「表示用整形」store
  • アクション受け渡しパターン編 — setTab のような action を props で渡すデザイン
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground 追加記事。UI 制御だけの store を切り出し、ビジネスデータ用 store と分離する設計を読み解く。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の Store Monitor サイドパネルを selector だけで作る

    全 store の値をリアルタイムに監視するサイドパネルを selector だけで構成し、再レンダリング範囲を局所化するパターンを解説する。

  • Zustand Test の Form / Monitor / TabBar を Storybook で分離する

    Zustand に依存する UI を Storybook で取り扱うために、store のリセット / decorator / play function を組み合わせて状態をストーリー単位で固定する手順を解説する。

  • Next.js 15 + Zustand でモーダル状態管理を作る

    Zustand を使ってモーダルの開閉状態をグローバルに管理する実装例。複数モーダルへの対応と型安全な設計を示す。

  • Zustand Test の保存処理を非同期 action と loading / error 状態で扱う

    saveSettings のような非同期処理を store のアクションに閉じ込め、status: 'idle'|'loading'|'error'|'success' を 1 つのフィールドで表現する書き方を解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る