👍

React公式ドキュメント『「ありえない」stateを解消する』の実装例

2024/09/08に公開

概要

React公式ドキュメントの「#state を使って入力に反応する」では、フォームを題材に、UIを宣言的に考える方法が解説されています。この解説では、stateを設計する際の重要な条件として「矛盾が生じないこと」が挙げられています。しかし、最終的に提示されたコードには依然として矛盾が残されており、その解消方法については読者への課題として示されています。

そこで、この記事では、矛盾が解消されたフォームの実装例を示します。ただし、以下の点に留意してください。

  • この記事では題材にされているフォームの仕様を説明していません。そのため、先に公式ドキュメント「#state を使って入力に反応する」を読むことを勧めます。
  • 公式ドキュメントでは、useReducerを使用することが推奨されていますが、この記事の実装例ではuseReducerではなく、useStateとCustom Hooksを使用しています。

矛盾が解消されたフォームの実装例

useForm.ts
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 };
};

App.tsx
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以外になる」といった矛盾が型によって防がれています。

型に守られていることを実感出来る場面

  1. 「ありえない」状態に更新しようとするとコンパイルエラー
    例えばstatus"success"に設定した場合に、errornull以外の値を入れるとコンパイルエラーが発生します。
  2. タグによる状態の絞り込み
    例えば、App.tsxuseFormから取得したerrorの型はError | nullですが、status"error"の条件でフィルタされると、errorの型はErrorに絞り込まれます。これにより、error.messageを安全に参照できます。

有名ライブラリではタグ付きユニオンを活用しているのか

私の実装が正しいのかを確認するために、いくつかの有名なライブラリで内部実装がタグ付きユニオンを活用しているのか調査しました。

react-hook-form(@7.53.0)

タグ付きユニオンでは実装されていませんでした。
https://github.com/react-hook-form/react-hook-form/blob/0757d9a4a6f2d3ec47f7b799bba8713edd507285/src/types/form.ts#L146-L161

formik(@2.4.6)

こちらもタグ付きユニオンでは実装されていませんでした。
https://github.com/jaredpalmer/formik/blob/2618cc4e6af0b2b1fcbff93936b5d3c68809b791/packages/formik/src/types.tsx#L40-L55

TanStack Form(@0.32.0)

こちらも、タグ付きユニオンでは実装されていませんでした。
https://github.com/TanStack/form/blob/fa0c25a8c8e4ba5b60ae9d1d3dc10e422260d985/packages/form-core/src/FormApi.ts#L217-L294

TanStack Query(@5.55.0)

Formではないですが、こちらはタグ付きユニオンで定義していました。例えば、fetchingの時にはdataがundefinedであることが型で保証されていますね。
私が示した実装例に似ていて、実装の方針が大きく外れていないことが確認出来ました。

https://github.com/TanStack/query/blob/353e4ad7291645f27de6585e9897b45e46c666fb/packages/query-core/src/types.ts#L666-L751

まとめ

タグ付きユニオンを使用することで、矛盾したstateがないことが保証されたフォームを作ることが出来ました。複雑なstate管理を行う際は、タグ付きユニオンを活用して、堅牢な型安全性を確保することを検討してみてください。

参考

Discussion