概要
Zustand Test の profileStore は、settingsStore から流れ込んだ値を 表示用に整形した形 で保持します。
settingsStore は「ユーザーが入力した生に近い値」、profileStore は「画面に出すために整形済みの値」、という役割分担です。
分けると次の利点があります。
- 整形ルールが 1 か所に集約 される。同じ表示処理を複数のコンポーネントで書き直さなくて済む
- 入力中の値を整形値に汚染させない。フォームの再編集で表示の整形が壊れない
- 整形を変えたいとき、
profileStoreだけ触れば良い。settings 側のスキーマと独立して進化できる
インストール
npm install zustand
profileStore の最小実装
整形ロジックは updateFrom action に閉じ込めます。外から displayName を直接 set する経路は作りません。整形を経由しない値の混入を防ぐためです。
// src/store/profileStore.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type { FormData } from "@/schemas/formSchema";
const INITIAL_PROFILE = {
displayName: "",
emailLabel: "",
};
type ProfileInput = Pick<FormData, "name" | "email">;
type ProfileState = {
displayName: string;
emailLabel: string;
updateFrom: (input: ProfileInput) => void;
reset: () => void;
};
export const useProfileStore = create<ProfileState>()(
devtools(
(set) => ({
...INITIAL_PROFILE,
updateFrom: ({ name, email }) =>
set(
{
displayName: formatDisplayName(name),
emailLabel: formatEmailLabel(email),
},
false,
"updateFrom"
),
reset: () => set(INITIAL_PROFILE, true, "reset"),
}),
{ name: "ProfileStore" }
)
);
引数の型は Pick<FormData, "name" | "email"> で 依存範囲を表明 します。後で email を見るのを止めたくなったら、ここを直すだけで型が追従します。
整形関数を 1 ファイルに集める
formatDisplayName のような 整形関数だけ を別ファイルに切り出すと、ユニットテストが書きやすくなります。store には「呼び方」と「保持の仕方」だけを残し、ロジックを外に出します。
// src/store/profileFormatters.ts
export function formatDisplayName(name: string): string {
const trimmed = name.trim();
if (trimmed.length === 0) return "(未設定)";
return trimmed.toUpperCase();
}
export function formatEmailLabel(email: string): string {
const trimmed = email.trim();
if (trimmed.length === 0) return "(未登録)";
return trimmed.toLowerCase();
}
整形関数は 純粋関数(同じ入力なら同じ出力・副作用なし)に保ちます。Zustand に依存させないので、profileFormatters.test.ts で単体テストできます。
storeSync で settings から流し込む
settings 側の購読は subscribeWithSelector + shallow で 必要なフィールドだけ に絞ります。フォーム上で rate だけ変わったときに updateFrom が呼ばれないようにするためです。
// src/store/storeSync.ts
import { shallow } from "zustand/shallow";
import { useSettingsStore } from "./settingsStore";
import { useProfileStore } from "./profileStore";
export function setupProfileSync() {
return useSettingsStore.subscribe(
(s) => ({ name: s.name, email: s.email }),
({ name, email }) => {
useProfileStore.getState().updateFrom({ name, email });
},
{ equalityFn: shallow }
);
}
オブジェクトを返す selector では { equalityFn: shallow } が必須です。指定しないと毎回別参照になり、rate だけ変わっても updateFrom が呼ばれてしまいます。
フォールバック値を「整形」に含める
未入力時に (未設定) などのプレースホルダを出す処理は 整形関数の責任 に置きます。表示側に if を書かない分、テンプレートが薄く保てます。
// src/components/profile/ProfileCard.tsx
"use client";
import { useShallow } from "zustand/react/shallow";
import { useProfileStore } from "@/store/profileStore";
export function ProfileCard() {
const { displayName, emailLabel } = useProfileStore(
useShallow((s) => ({ displayName: s.displayName, emailLabel: s.emailLabel }))
);
return (
<article>
<h2>{displayName}</h2>
<p>{emailLabel}</p>
</article>
);
}
UI 側は if (!name) の分岐を持たず、profileStore の値をそのまま流す だけです。プレースホルダ文言を変えたいときは整形関数 1 か所を直します。
整形を変えるときの影響範囲
整形ルールを変える要件が来たとき、触る場所は次の 3 つに収まります。
profileFormatters.tsの整形関数- (表示の見た目を変えるなら)
ProfileCardなどの表示コンポーネント - (テストがあれば)
profileFormatters.test.ts
settingsStore のスキーマや storeSync の購読には触れずに済みます。変更の波及範囲が層で区切られている のが、view-model 用の store を切り出した最大の見返りです。
ポイント
- 表示用整形は 専用 store + 専用 action に閉じ込め、外から直接
setしない - 整形関数は store の外に切り出して 純粋関数 にする。単体テストが書きやすくなる
- settings からの購読は
subscribeWithSelector+shallowで 必要なフィールドだけ に絞る - フォールバック表示は 整形関数の責任。UI 側に
ifを書かないとテンプレートが薄くなる - 整形ルールの変更は
profileFormatters.tsだけで完結する。settings 側に波及しない
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — 4 store 全体の役割分担
- 派生状態編 —
subscribeWithSelector+shallowの使い分け - storeSync テスト編 — 整形関数と同期レイヤをテストする
- Zustand Test (Build) — 完成例と外部デモ