はじめに
Dress Code株式会社で直近HR Forceの開発をしている、ふるしょうです。
HR領域のSaaSは、複雑なフォームの機能要件が伴うことが少なくありません。
例えば、入社手続きにおける家族情報の登録など、動的フィールドの表示/非表示や編集制御、複雑な依存関係を持つ計算フィールド、再起的な階層構造、同一ページ内に複数の独立したフォームインスタンスが存在する場合の適切な状態管理が必要になります。
弊社では、このような複雑なフォーム要件に対応するため、Zustand と React Context を組み合わせたアーキテクチャパターンを採用し、開発を進めています。
本記事では、この組み合わせを前提に、DRESS CODEで取り入れているデザインパターンの具体例と設計内容について紹介します。
TL;DR
- Zustand + React Context パターンにより、コンポーネントツリーレベルでの状態分離を実現し、複数フォームインスタンスの独立した状態管理が可能
- Immer middlewareを活用し、直感的なコーディングでイミュータブルな状態更新を実現
- 型安全なユーティリティ関数 を実装し、コンテキスト生成とStore作成を統一し、開発体験を向上
- Slice Patternによる関心の分離で、大規模フォームアプリケーションの保守性、拡張性、チーム開発効率、テスト容易性を向上
- 純粋関数のアプローチとSlice Patternによりテスト容易性を確保し、Vitest でのユニットテストを効率化
Zustand の基本的な利用方法と課題
Zustand は、軽量で最小限の API を持つ状態管理ライブラリです。その基本的な使い方は非常にシンプルです。
import { create } from "zustand";
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return (
>
Count: {count}
button onClick={increment}>+/button>
/>
);
}
この例のように、create
関数でStoreを作成し、生成されたカスタムフック (useStore
) をコンポーネント内で呼び出すのが基本的な流れです。
しかし、このシンプルなアプローチは、大規模なアプリケーションや、特定の要件(特に複雑なフォーム管理)においていくつかの課題に直面します。
- インスタンス分離の難しさ: 通常、Zustand で作成されたStoreはアプリケーション全体で共有されるシングルトンとして振る舞います。そのため、同じStore定義を使用して、ページ内の異なる場所で完全に独立した状態を持つ複数のフォームインスタンスを作成するためにはSlice Patternの活用やContextProviderの利用が必要になります。
- propsからの初期化: 親コンポーネントから渡される初期値や設定に基づいてStoreの状態を初期化する場合、シンプルな
create
関数だけでは煩雑なコードになりがち。 - テスト時の分離: シングルトンであるため、テストケース間でStoreの状態が共有されてしまい、テストの独立性を保つためのセットアップやクリーンアップが複雑になることがあります。
これらの課題、特にマルチインスタンスの分離に対応するために React Context と組み合わせたパターンを採用しました。
React Context を活用したマルチインスタンス対応
複数フォームインスタンスの独立した状態管理を実現するために、Zustand Storeを React Context と統合するcreateZustandContext
というカスタムユーティリティ関数を運用しています。
zustand-context-pattern.ts
import { type Context, createContext, useContext, useRef } from "react";
import { useStore } from "zustand";
import {
type StoreCreatorWithMiddlewares,
createWithMiddlewares,
} from "./zustand-with-middlewares";
type StoreState> = ReturnTypetypeof createWithMiddlewaresState>>;
type StoreContextState> = ContextStoreState> | null> & {
createStore: () => StoreState>;
};
export function createZustandContextState extends object>(
storeCreator: StoreCreatorWithMiddlewaresState>,
) {
const context = createContextStoreState> | null>(null);
const createStore = () => useRef(createWithMiddlewares(storeCreator)).current;
Object.assign(context, { createStore });
function useZustandStoreT = State>(
selector: (state: State) => T = (state) => state as unknown as T,
): T {
const store = useContext(context);
if (!store) throw new Error("Missing StoreContext.Provider in the tree");
return useStore(store, selector);
}
return {
context: context as StoreContextState>,
useStore: useZustandStore,
};
}
この実装の主な特徴とメリット
- コンテキストと統合されたStore生成:
createStore
メソッドを生成された Context オブジェクトに直接アタッチしています。これにより、コンポーネントツリー内で Provider を配置する際に、その場で新しい Zustand Storeインスタンスを簡単に生成し、Provider に渡すことができます。 - カスタムフックの自動生成: 生成されたコンテキスト (
context
) に対応するカスタムフック (useStore
) を返します。このフックを使用することで、特定のコンテキスト Provider 配下にあるコンポーネントは、型安全にそのコンテキストに関連付けられたStoreの値やアクションにアクセスできます。 - セレクタパターンの組み込み: 返される
useStore
フックは、Zustand のセレクタパターンをデフォルトで利用可能です。これにより、コンポーネントはStore全体ではなく、必要とするStoreの一部だけを購読し、不要な再レンダリングを防ぐことができます。
実際の使用例
usePlaceFormStore.ts
type PlaceFormState = {
values: { id: string; name: string; ;
errors: Recordstring, string>;
actions: { update: (callback: (values: PlaceFormState['values']) => void) => void; };
};
export const { context: PlaceFormStoreContext, useStore: usePlaceFormStore } =
createZustandContextPlaceFormState>((set, get) => ({
values: {
id: "",
name: "",
},
errors: {},
actions: {
update: (callback) =>
set((state) => {
callback(state.values);
}),
},
}));
PlaceFormProvider.tsx
import { PlaceFormStoreContext } from './usePlaceFormStore';
type FormProviderProps = {
children: React.ReactNode;
};
export const PlaceFormProvider = ({ children }: FormProviderProps) => {
const store = PlaceFormStoreContext.createStore();
return (
PlaceFormStoreContext.Provider value={store}>
{children}
PlaceFormStoreContext.Provider>
);
};
FormField.tsx
import { usePlaceFormStore } from './usePlaceFormStore';
function FormField() {
const value = usePlaceFormStore((state) => state.values.name);
const update = usePlaceFormStore((state) => state.actions.update);
return (
input
value={value}
onChange={(e) => update((values) => { values.name = e.target.value; })}
/>
);
}
このパターンにより、PlaceFormProvider
が使用されるたびに新しい独立したStoreインスタンスが生成され、その Provider 配下のコンポーネントツリーでのみ、そのStoreが利用可能になります。これにより、同じページ内に複数の拠点入力フォームがあっても、それぞれの状態が完全に分離されるという、インスタンス分離を実現します。
イミュータビリティとデバッグ
もう一つの重要なユーティリティは、Zustand Store作成時に共通のミドルウェア(特に Immer と DevTools)を適用するためのzustand-with-middlewares.ts
です。
zustand-with-middlewares.ts
import { type StateCreator, create } from "zustand";
import { devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export type StoreCreatorWithMiddlewaresTState> = StateCreator
TState,
[["zustand/devtools", never], ["zustand/immer", never]]
>;
export function createWithMiddlewaresTState>(
createState: StoreCreatorWithMiddlewaresTState>
) {
const slice = createTState>()(devtools(immer(createState)));
return slice;
}
このユーティリティが提供するメリット
- Immer による直感的な状態更新:
immer
ミドルウェアをデフォルトで適用することで、Storeのアクション内でset
関数を使用すると、Immer が変更を追跡し、イミュータブルな新しい状態オブジェクトが生成されます。これは特にネストが深いオブジェクトや配列の更新を扱うフォームで非常に有効です。 - 開発ツールの統合:
devtools
ミドルウェアにより、Storeの状態変化が自動的に Redux DevTools (ブラウザ拡張機能) に記録されます。これにより、状態の変遷を時系列で追跡し、バグの原因特定やデバッグが格段に容易になります。 - 型安全性の向上: ミドルウェアの適用順序と、それによって変更される
set
関数の型シグネチャをStoreCreatorWithMiddlewares
型エイリアスで明示的に管理しています。これにより、ミドルウェアを含む Zustand Storeの型安全性が向上します。
これにより、先ほどの例のように、アクション内で以下のような直感的なコードを書くことができます
update: (callback) =>
set((state) => {
callback(state.values);
});
関心の分離とスケーラビリティ
大規模なアプリケーションの状態管理において、Store全体を単一の大きなオブジェクトとして管理することは、コードの見通しを悪くし、保守性や拡張性を低下させます。Zustand の公式ドキュメントでも推奨されているSlice Pattern は、Storeを機能や関心ごとに「スライス (Slice)」と呼ばれる小さな塊に分割するアプローチです。これにより、各スライスが独自の状態、アクション、あるいはユーティリティ関数を管理し、それらを組み合わせて最終的なStoreを構築します。
DRESS CODEの動的なフォームを生成するためのモジュールでは、この Slice Pattern を積極的に採用しています。Storeの定義は、複数のスライスを作成し、それらを結合する形をとっています。
import { createZustandContext } from "@/lib/store/zustand-context-pattern";
import {
type StoreCreatorWithMiddlewares,
createWithMiddlewares,
} from "@/lib/store/zustand-with-middlewares";
import {
createActionsSlice,
createStateSlice,
createUtilitiesSlice,
} from "./slices";
import type { FormStore, ImmerSetFunction, SetAdapter } from "./types";
const createSetAdapter = (rawSet: ImmerSetFunction): SetAdapter => ({
sliceSet: (fn) => {
rawSet((state) => {
const partial = fn(state);
Object.assign(state, partial);
});
},
rawSet: (fn) => {
rawSet(fn);
},
});
const formStoreCreator: StoreCreatorWithMiddlewares = (
set,
get,
api
) => {
const state = createStateSlice({
form: undefined,
values: [],
touched: {},
errors: [],
isSubmitted: false,
isSubmitAttempted: false,
});
const adapter = createSetAdapter(set as ImmerSetFunction);
const actions = createActionsSlice(adapter.sliceSet, get, api);
const utilities = createUtilitiesSlice(adapter.sliceSet, get, api);
return {
...state,
...actions,
...utilities,
};
};
この Slice Pattern アプローチの利点は多岐にわたります。
- 機能ごとの責務分離: フォームの状態そのもの、状態を変更する操作、状態から情報を取得したり補助する関数 といったように、関心ごとにコードが明確に分割されます。
- 拡張性の向上: 新しい機能(例: 特定の種類の動的フィールド、複雑な計算ロジック)を追加する場合、影響範囲を特定の新しいスライスや既存のスライス内の限定された部分に閉じ込めることができます。これにより、既存コードへの影響を最小限に抑えつつ、安全に機能を追加できます。
- チーム開発の効率化: 複数の開発者が異なるスライスを並行して開発しやすくなります。コードのコンフリクトも減り、開発効率が向上します。
- テスト容易性: 各スライスは比較的独立した純粋関数に近い形になるため、モック化やスタブ化が容易になり、ユニットテストが書きやすくなります。
複雑な動的フィールド管理の実現
BtoB アプリケーションのフォームの複雑性の典型例として、ユーザーのアクションや他のフィールドの値に基づいてフォーム構造自体が変化する動的なフィールドや階層構造を備えたフォームがあります。
このアーキテクチャを用いて以下のように動的構造をサポートすることが可能です。
- 動的コレクション: ユーザーが任意に追加・削除できる、同じ構造を持つフィールドのグループの集約(例: 緊急連絡先、家族情報)。
- ネストされたグループ: フィールドグループがさらにその中に別のフィールドグループを含むような階層構造。(例:家族情報N人目)
- 条件付き表示: あるチェックボックスがオンになったら追加の入力フィールドが表示される、といった他のフィールド値に依存して表示・非表示が切り替わるフィールド。
これらの動的要素は、Zustand Store内の状態として管理され、それを操作するためのアクションが定義されます。例えば、動的コレクション関連のアクションスライスの一部は以下のようになります。
export const createDynamicCollectionSlice = (
set: SliceSetFunction,
get: () => FormStore
) => ({
getCollectionIndices: (collectionId: string) => {
const state = get();
},
addCollection: (collectionId: string) => {
},
removeCollection: (collectionId: string, collectionIndex: number) => {
},
});
これらのアクションや状態にはStoreからアクセスして利用できます。
DynamicCollectionAddButton.tsx
import { useForm } from './useForm';
function DynamicCollectionAddButton({ collectionId, readOnly }) {
const { getDynamicCollections, addCollection } = useForm(
(state) => ({
getDynamicCollections: state.getDynamicCollections,
addCollection: state.actions.addCollection,
})
);
const dynamicCollections = getDynamicCollections?.(collectionId);
if (readOnly || !dynamicCollections?.canAdd) {
return null;
}
return (
button onClick={() => addCollection(collectionId)}>
追加
button>
);
}
これにより、複雑な動的構造を持つフォームであっても、状態とロジックがStoreに集約され、コンポーネントはそれをシンプルに利用する形になるため、コードの可読性と保守性が高まります。
セレクタパターンの活用したパフォーマンス最適化
大規模なフォーム、特に多数のフィールドや複雑な計算を持つフォームでは、パフォーマンスが重要な課題となります。React コンポーネントは、状態が変化すると再レンダリングされる性質を持つため、効率的な状態管理パターンを採用しないと、不要な再レンダリングが多発し、アプリケーションの応答性が低下する可能性があります。
Zustand の useStore
フックは、第2引数にセレクタ関数を受け取ることができます。この関数はStore全体の状態を受け取り、コンポーネントが必要とする特定のオブジェクトの一部を返します。Zustand は、このセレクタ関数の戻り値が前回のレンダリング時から変更された場合にのみ、そのフックを呼び出しているコンポーネントを再レンダリングします。
DRESS CODEの動的フォーム生成用モジュールを利用するためのカスタムフックでは、内部でこのセレクタパターンを活用しています。
import { useFormStoreContext } from './useFormStore';
function FormField({ fieldId }) {
const value = useFormStoreContext((state) => state.getFieldValue(fieldId));
const error = useFormStoreContext((state) => state.getFieldError(fieldId));
return (
>
input value={value} />
{error && span>{error}span>}
>
);
}
セレクタを適切に使用することで、フォーム全体の状態オブジェクトが大きい場合でも、各コンポーネントが必要な最小限の情報のみに更新するようになります。これにより、特に大量のフィールドを持つフォームや、頻繁に状態が更新されるようなインタラクティブなフォームにおいて、パフォーマンス向上が期待できます。
独立したバリデーションロジック
バリデーションロジックもまた Slice Pattern を活用して、独立したバリデーションアクションのスライスとして実装されています。
validation-actions-slice.ts
export const createValidationActions = (
set: SliceSetFunction,
get: () => FormStore
) => ({
setErrors: (errors: Recordstring, string>) => {
set((state) => {
state.errors = errors;
});
},
validateField: (fieldId: string): boolean => {
const state = get();
const attribute = state.getAttribute(fieldId);
const value = state.getFieldValue(fieldId);
const validation = validateField(attribute, value);
if (!validation.isValid) {
set((state) => {
state.errors = {
...Object.fromEntries(Object.entries(state.errors).filter(([id]) => id !== fieldId)),
[fieldId]: validation.errorMessage,
};
});
return false;
}
set((state) => {
state.errors = Object.fromEntries(Object.entries(state.errors).filter(([id]) => id !== fieldId));
});
return true;
},
validateAll: (): boolean => {
const state = get();
},
});
DRESS CODEでは、Zodと組み合わせて、宣言的にバリデーションルールを定義し、それを基にバリデーションを実行する純粋関数を作成しています。これにより、バリデーションロジック自体もテストが容易になります。
バリデーションメッセージやフィールドのラベル、ヘルプテキストなどの多言語対応については、i18n基盤と連携して実現しています。i18n基盤では、.tsファイルで型安全な翻訳キー運用をしており、zodのカスタムメッセージはNameSpace分割して管理しています。
Vitest によるStoreのユニットテスト
Slice Pattern と、状態更新ロジックを可能な限り純粋関数として切り出すアプローチの大きな利点の一つは、テスト容易性の向上です。各スライスやその内部関数を独立してテストできるため、テストコードの記述が簡潔になります。
また、動的コレクション機能のような複雑なロジックを含むスライスについても、同様に独立したテストケースを作成できます。以下は、Zustand Storeの状態をモックオブジェクトとして作成し、特定のアクションを呼び出した後の状態変化を検証しています。
dynamic-collection-slice.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createDynamicCollectionSlice } from "../dynamic-collection-slice";
import { type FormStore } from "../../form-store";
describe("dynamic-collection-slice", () => {
let mockSet: any;
let mockGet: any;
let mockStore: any;
let actions: ReturnTypetypeof createDynamicCollectionSlice>;
let state: PartialFormStore>;
beforeEach(() => {
state = {
form: {
id: "entry-info",
title: "家族情報",
attributeCollections: [
{
id: "family-info",
isDynamic: true,
minCount: 0,
maxCount: 5,
attributeGroupSettings: [],
},
],
},
values: [
{
fieldId: "family-info.0.group.0.attr1",
attributeId: "attr1",
value: "コレクション0の値",
},
{
fieldId: "family-info.1.group.0.attr1",
attributeId: "attr1",
value: "コレクション1の値",
},
],
errors: [
{
fieldId: "family-info.0.group.0.attr1",
message: "エラー0",
},
{
fieldId: "family-info.1.group.0.attr1",
message: "エラー1",
},
],
disabledCollections: [],
setCollectionEnabled: vi.fn(),
};
mockSet = vi.fn((updater) => {
if (typeof updater === "function") {
const updates = updater(state);
state = { ...state, ...updates };
} else {
state = { ...state, ...updater };
}
return state;
});
mockGet = vi.fn(() => state);
mockStore = {};
actions = createDynamicCollectionSlice(mockSet, mockGet, mockStore);
});
describe("removeCollection", () => {
it("指定されたコレクションに関連する値とエラーを削除すること", () => {
actions.removeCollection(
"family-info",
1,
);
expect(state.values).toEqual([
{
fieldId: "family-info.0.group.0.attr1",
attributeId: "attr1",
value: "コレクション0の値",
},
]);
expect(state.errors).toEqual([
{
fieldId: "family-info.0.group.0.attr1",
message: "エラー0",
},
]);
});
it("無効化リストからも該当のコレクションを削除すること", () => {
state.disabledCollections = [
{
collectionId: "family-info",
collectionIndex: 1,
},
];
actions.removeCollection(
"family-info",
1,
);
expect(state.disabledCollections).toEqual([]);
});
});
});
このように、各スライスやその内部ロジックを独立してユニットテストできることで、大規模フォームアプリケーションでも高いテストカバレッジを維持し、コードの信頼性を担保することができます。
まとめ
Zustand と React Context を組み合わせたパターンは、特に複雑なフォーム管理に対して非常にスケーラブルな設計となります。
単純な CRUD 操作に関連するフォームから、本記事で焦点を当てたような複雑な動的フィールドやマルチインスタンスを伴うフォームまで、幅広いユースケースに適用可能です。
- React Client Components 内で、複数の独立した状態を持つコンポーネント群(例: 同一ページ内の複数フォーム)を管理する必要がある場合
- ユーザーインタラクションや外部データに基づいて、フォーム構造や内容が動的に変化する複雑なフォームを実装する場合
- チーム開発において、コードの責務を明確に分離し、保守性と拡張性を高く保ちたい大規模なフロントエンドアプリケーション
この記事で紹介したパターンが、複雑なフォーム状態管理の課題解決の一助となれば幸いです!
Views: 0