2026年6月28日
2026年6月28日
React公式ドキュメントの「stateの管理」を読んだことがある方なら、UIを宣言的に設計するために status という状態(state)を導入する手法に見覚えがあるでしょう。
あるWebサイトでは、フォームの挙動を制御するために以下のようなステートマシンのアプローチが紹介されています。
type FormStatus = 'typing' | 'submitting' | 'success' | 'error';
これはUIの表示制御において非常に強力なアプローチですが、一歩進めて「ビジネスルール(ドメインモデル)」の観点からこのstatusを捉え直すと、フロントエンドの堅牢性はさらに向上します。
本記事では、このstatusが必要なドメインモデルを題材に、開発者が書きがちなコードと、状態遷移の思想を取り入れた適切なコードを対比しながら解説します。
テーマ: オンライン試験の解答提出
今回は、公式ドキュメントのフォームの例を発展させ、以下のビジネスルールを持つ「オンライン試験の解答提出」というドメインを考えます。
ビジネスルール(不変条件)
解答中 (
typing): ユーザーは自由に解答を編集できる。提出中 (
submitting): サーバーへの送信中。二重送信を防ぐため、解答の編集や再提出ボタンの連打は不可。提出成功 (
success): 無事に受理された状態。一度成功したら、二度と解答の編集も再提出もできない。エラー (
error): 送信失敗。エラーメッセージを表示し、再試行できる。
1. 開発者が書きがちなコード
まずは、多くの現場で見かける、UIの都合だけで状態をバラバラに管理してしまっているコードです。
import React, { useState } from "react";
export const BadExamSubmission: React.FC = () => {
const [answer, setAnswer] = useState("");
const [status, setStatus] = useState<"typing" | "submitting" | "success" | "error">("typing");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleSubmit = async () => {
// 連打防止のためのチェックがUI層に漏れ出ている
if (status === "submitting") return;
// 「一度成功したら提出できない」というルールが画面側の防御に依存している
if (status === "success") {
alert("既に提出済みです");
return;
}
if (answer.trim() === "") {
alert("解答が空です");
return;
}
setStatus("submitting");
try {
// 擬似APIコール
await saveAnswer(answer);
setStatus("success");
} catch (e) {
setStatus("error");
setErrorMessage("送信に失敗しました。内容を確認して再試行してください。");
}
};
return (
<div>
{/* statusがsuccessの時でも、コードの書き方次第でtextareaが活性化してしまうリスクがある */}
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
disabled={status === "submitting" || status === "success"}
/>
<button onClick={handleSubmit} disabled={status === "submitting"}>
{status === "submitting" ? "提出中..." : "試験を提出する"}
</button>
{status === "error" && <p style={{ color: "red" }}>{errorMessage}</p>}
{status === "success" && <p style={{ color: "green" }}>提出が完了しました。</p>}
</div>
);
};
// 擬似的なAPI関数
const saveAnswer = (ans: string) => new Promise((res) => setTimeout(res, 1000));
このコードの何が悪いのか?
一見正しく動くように見えますが、以下の致命的な課題を抱えています。
setStatus("success")やsetStatus("typing")をどこからでも呼び出せるため、プログラムのバグによって「提出成功(success)から解答中(typing)に逆戻りする」といった不正な状態遷移を簡単に引き起こせます。- 「一度提出したら変更できない」「空の解答は出せない」といったドメインのルールが、コンポーネントのイベントハンドラーの中に
if文として埋もれています。
2. 適切なコード
次に、「stateはドメインモデルの振る舞い(状態遷移)そのものである」という思想に基づいた設計です。
TypeScriptの「判別可能なユニオン型(Discriminated Unions)」を使い、その状態の時にしか存在し得ないデータを型で縛り、状態遷移のルールを純粋関数(ドメイン層)に隔離します。
1. ドメイン層
Reactのことを一切知らない、純粋なビジネスルールのみを持つファイルです。
// 状態ごとのデータ構造を厳格に定義
export type TypingState = { status: "typing"; answer: string };
export type SubmittingState = { status: "submitting"; answer: string };
export type SuccessState = { status: "success"; finalizedAnswer: string; submittedAt: Date };
export type ErrorState = { status: "error"; answer: string; errorReason: string };
// stateの定義
export type ExamState = TypingState | SubmittingState | SuccessState | ErrorState;
// 起きうるイベントの定義
export type ExamEvent =
| { type: "EDIT"; payload: { text: string } }
| { type: "START_SUBMIT" }
| { type: "SUBMIT_SUCCESS"; payload: { submittedAt: Date } }
| { type: "SUBMIT_FAILURE"; payload: { reason: string } };
// 状態遷移のルール(ドメインモデルの振る舞い)
export const ExamDomain = {
// 解答の編集はtypingやerrorの時だけ受け付ける
edit(state: TypingState | ErrorState, text: string): TypingState {
return { status: "typing", answer: text };
},
// 提出開始(typingやerrorからのみ遷移可能)
startSubmit(state: TypingState | ErrorState): SubmittingState | ErrorState {
if (state.answer.trim() === "") {
return {
status: "error",
answer: state.answer,
errorReason: "解答が空の状態で提出することはできません"
};
}
return { status: "submitting", answer: state.answer };
},
// 提出成功(submittingからのみ遷移可能。成功データに変換され、これ以降編集不可に)
confirmSuccess(state: SubmittingState, submittedAt: Date): SuccessState {
return {
status: "success",
finalizedAnswer: state.answer,
submittedAt
};
},
// 提出失敗
handleFailure(state: SubmittingState, reason: string): ErrorState {
return {
status: "error",
answer: state.answer,
errorReason: reason
};
}
};
2. プレゼンテーション層
UI側では、useReducerを使ってこのドメインロジックを呼び出します。Reducerは自ら判断せず、ドメインモデルに「今の状態を次の状態に変えてくれ」と委ねるだけの存在になります。
import React, { useReducer, useEffect } from "react";
import { ExamState, ExamEvent, ExamDomain } from "../domain/ExamDomain";
const examReducer = (state: ExamState, event: ExamEvent): ExamState => {
// 現在の状態(status)に応じて、ドメインモデルの対応する振る舞いのみを呼び出す
switch (state.status) {
case "typing":
case "error":
if (event.type === "EDIT") return ExamDomain.edit(state, event.payload.text);
if (event.type === "START_SUBMIT") return ExamDomain.startSubmit(state);
return state;
case "submitting":
if (event.type === "SUBMIT_SUCCESS") return ExamDomain.confirmSuccess(state, event.payload.submittedAt);
if (event.type === "SUBMIT_FAILURE") return ExamDomain.handleFailure(state, event.payload.reason);
return state;
case "success":
// success状態はどのようなイベントが来ても状態遷移を拒否
return state;
default:
return state;
}
};
const initialState: ExamState = { status: "typing", answer: "" };
export const GoodExamSubmission: React.FC = () => {
const [state, dispatch] = useReducer(examReducer, initialState);
useEffect(() => {
if (state.status !== "submitting") return;
const executeSubmission = async () => {
try {
// 実際の送信処理(state.answerは型ガードにより安全に取得可能)
await saveAnswer(state.answer);
dispatch({ type: "SUBMIT_SUCCESS", payload: { submittedAt: new Date() } });
} catch (error) {
dispatch({ type: "SUBMIT_FAILURE", payload: { reason: "通信エラーが発生しました" } });
}
};
executeSubmission();
}, [state.status, state.status === "submitting" ? state.answer : undefined]);
const handleSubmit = () => {
dispatch({ type: "START_SUBMIT" });
};
return (
<div>
{/* 型に基づいた安全なUIの出し分け */}
{state.status !== "success" ? (
<textarea
value={state.answer}
onChange={(e) => dispatch({ type: "EDIT", payload: { text: e.target.value } })}
disabled={state.status === "submitting"}
/>
) : (
<p>解答: <strong>{state.finalizedAnswer}</strong></p>
)}
{state.status !== "success" && (
<button onClick={handleSubmit} disabled={state.status === "submitting"}>
{state.status === "submitting" ? "提出中..." : "試験を提出する"}
</button>
)}
{state.status === "error" && <p style={{ color: "red" }}>{state.errorReason}</p>}
{state.status === "success" && (
<p style={{ color: "green" }}>
{state.submittedAt.toLocaleTimeString()} に提出が完了しました。
</p>
)}
</div>
);
};
const saveAnswer = (ans: string) => new Promise((res) => setTimeout(res, 1000));
何が変わったのか?
適切なコードへとリファクタリングしたことで、システムは以下のように改善されました。
不正な状態を表現できなくなった
SuccessState 型の定義を見ると、そこにはfinalizedAnswer(確定された文字)しか存在しません。また、successになった後はreducerがあらゆる遷移をブロックします。これにより、「提出後に勝手に値が書き換わるバグ」をコードの構造レベルで防げるようになりました。
UI制御とビジネスルールが分離された
コンポーネント(UI層)の役割は「ボタンが押されたらイベントを通知する(dispatch)」ことだけです。「本当にそのタイミングで提出してよいか」「解答が空ではないか」というドメインのバリデーションはすべてExamDomain.tsに集約されています。
テスト可能性が向上した
「解答が空なら提出できない」「提出成功後は状態が変わらない」という仕様のテストを書く際、Reactのコンポーネントをレンダリングする必要はありません。ピュアなTypeScript関数であるExamDomain.startSubmitを呼び出すだけの、シンプルな単体テストを書くことで動作確認ができます。
まとめ
React公式ドキュメントにかかれているstatusによるUI管理は良いアプローチです。しかし、それをコンポーネントの中だけで完結させるのではなく、「ドメインモデルが持つべき状態遷移のルール」として外側に切り出すことで、フロントエンドの複雑性が下がります。
stateをただの「データの器」にせず、「ビジネスルールを表現するステートマシン」としてモデリングすることで、「UIの都合」と「ビジネスの都合」がきれいに分離され、開発者の認知負荷も下がるという利点もあります。
これからのフロントエンドには「複雑化するWebアプリのルールをどう制御するか」という、アーキテクチャの視点が不可欠です。
