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

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

Zustand Test の profileStore で表示用整形を 1 か所にまとめる

settingsStore の値を trim・大文字化・フォールバックといった整形を施して保持する profileStore の最小設計を解説する。

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

対応バージョン

nextjs 15react 19zustand 5

前提環境

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

概要

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 つに収まります。

  1. profileFormatters.ts の整形関数
  2. (表示の見た目を変えるなら)ProfileCard などの表示コンポーネント
  3. (テストがあれば)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) — 完成例と外部デモ

注意点

Playground 追加記事。表示用に整形した値を保持する store を独立させる利点と、整形ルールを 1 か所に集約する書き方を読み解く。

リポジトリを見る

関連サンプル

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

  • Zustand Test の suggestedRate を subscribeWithSelector で派生する

    settingsStore.rate から detailStore.suggestedRate を導出する派生状態の作り方を、subscribeWithSelector と shallow を軸に解説する。

  • Zustand Test の storeSync を Vitest でユニットテストする

    settings から profile へ subscribe で同期するレイヤを、Vitest で「呼ばれた / 呼ばれない」「整形が正しい」を確かめるテストの書き方を解説する。

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

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

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

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

  • Zustand Test の settingsStore を BroadcastChannel で複数タブに同期する

    BroadcastChannel API で別タブの settingsStore に変更をブロードキャストし、エコーバックを避けながら受信側を更新する手順を解説する。

関連仕様

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

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