概要
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) — 完成例と外部デモ