React公式ドキュメント『「ありえない」stateを解消する』の実装例
概要
React公式ドキュメントの「#state を使って入力に反応する」では、フォームを題材に、UIを宣言的に考える方法が解説されています。しかし、解説の中でstateが満たすべき条件として「矛盾が生じないこと」が挙げられていましたが、最終的に提示されたコードには依然として矛盾が生じており、その矛盾の解消は読者への課題として示されたままになっています。
そこで、この記事では、矛盾が解消されたフォームの実装例を示します。ただし、以下の点に留意してください。
- この記事では題材にされているフォームの仕様を説明していません。そのため、先に公式ドキュメント「#state を使って入力に反応する」を読むことを勧めます。
- 公式ドキュメントでは、useReducerを使用することが推奨されていますが、この記事の実装例ではuseReducerではなく、useStateとCustom Hooksを使用しています。
矛盾が解消されたフォームの実装例
import { FormEvent, FormEventHandler, useState } from "react";
type Empty = {
status: "empty";
answer: "";
error: null;
};
type Typing = {
status: "typing";
answer: string;
error: null;
};
type Submitting = {
status: "submitting";
answer: string;
error: null;
};
type FormSuccess = {
status: "success";
answer: string;
error: null;
};
type FormError = {
status: "error";
answer: string;
error: Error;
};
type FormState = Empty | Typing | Submitting | FormSuccess | FormError;
export const useForm = () => {
const [formState, setFormState] = useState<FormState>({
status: "empty",
answer: "",
error: null,
});
const handleTextareaChange = (e: FormEvent<HTMLTextAreaElement>) => {
const answer = e.currentTarget.value;
if (answer.length === 0) {
setFormState({
status: "empty",
answer: "",
error: null,
});
} else {
setFormState({
status: "typing",
answer: answer,
error: null,
});
}
};
// submitHandlerを引数として受け取り、FormEventHandler<HTMLFormElement>を返す設計はreact-hook-formを参考にしています。
const handleSubmit = (
submitHandler: (answer: string) => Promise<void>
): FormEventHandler<HTMLFormElement> => {
return async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setFormState({
status: "submitting",
answer: formState.answer,
error: null,
});
try {
await submitHandler(formState.answer);
setFormState({
status: "success",
answer: formState.answer,
error: null,
});
} catch (error) {
if (error instanceof Error) {
setFormState({
status: "error",
answer: formState.answer,
error: error,
});
}
}
};
};
return { formState, handleSubmit, handleTextareaChange };
};
import { useForm } from "./useForm";
// useFormの受け取り部分以外はほとんど公式ドキュメントと同じです。
function App() {
const {
handleSubmit,
handleTextareaChange,
formState: { error, answer, status },
} = useForm();
if (status === "success") {
return <h1>That's right!</h1>;
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form onSubmit={handleSubmit(submitForm)}>
<textarea
value={answer}
onChange={handleTextareaChange}
disabled={status === "submitting"}
/>
<br />
<button disabled={status === "empty" || status == "submitting"}>
Submit
</button>
{status === "error" && <p className="Error">{error.message}</p>}
</form>
</>
);
}
export default App;
const submitForm = (answer: string) => {
// Pretend it's hitting the network.
return new Promise<void>((resolve, reject) => {
setTimeout(() => {
const shouldError = answer.toLowerCase() !== "lima";
if (shouldError) {
reject(new Error("Good guess but a wrong answer. Try again!"));
} else {
resolve();
}
}, 1500);
});
};
解説: タグ付きユニオンで「ありえない」stateを解消する
強調したいのは、Empty、Typing、Submitting、FormSuccess、FormErrorの各状態をstatus
のタグ付きの型で定義し、それらをユニオン型として結合している点です。これにより、公式ドキュメントで懸念されていた「"status"がsuccessの場合にerrorがnull以外になる」といった矛盾が型によって防がれています。
型に守られていることを実感出来る場面
- 「ありえない」状態に更新しようとするとコンパイルエラー
例えばstatus
を"success"
に設定した場合に、error
にnull
以外の値を入れるとコンパイルエラーが発生します。
- タグによる状態の絞り込み
例えば、App.tsx
でuseForm
から取得したerror
の型はError | null
ですが、status
が"error"
の条件でフィルタされると、error
の型はError
に絞り込まれます。これにより、error.message
を安全に参照できます。
有名ライブラリではタグ付きユニオンを活用しているのか
私の実装が正しいのかを確認するために、いくつかの有名なライブラリで内部実装がタグ付きユニオンを活用しているのか調査しました。
react-hook-form(@7.53.0)
タグ付きユニオンでは実装されていませんでした。
formik(@2.4.6)
こちらもタグ付きユニオンでは実装されていませんでした。
TanStack Form(@0.32.0)
こちらも、タグ付きユニオンでは実装されていませんでした。
TanStack Query(@5.55.0)
Formではないですが、こちらはタグ付きユニオンで定義していました。例えば、fetchingの時にはdataがundefinedであることが型で保証されていますね。
私が示した実装例に似ていて、実装の方針が大きく外れていないことが確認出来ました。
まとめ
タグ付きユニオンを使用することで、矛盾したstateがないことが保証されたフォームを作ることが出来ました。複雑なstate管理を行う際は、タグ付きユニオンを活用して、堅牢な型安全性を確保することを検討してみてください。
参考
- Scott Wlaschin 著, 猪股健太郎 訳 「関数型ドメインモデリング」
- Naoya Ito 「関数型プログラミングと型システムのメンタルモデル」
Discussion