🈳

React Hook Form / defaultValues における「値なし」の扱い方

に公開

フロントエンドを担当している三谷です。

弊社の「値がない」を示す基本方針としては、UI層以上では undefined を扱い、バックエンドに送信する際に null に変換するという形を取っています。
この設計により「値がない」を表す型が複雑になることなく、APIやデータベースとの整合性も保ちやすくなります。

しかし、undefined を返す可能性があるデータ取得の関数の返り値をそのままdefaultValues に渡すと、フォーム管理に利用しているReact Hook Formの挙動と噛み合わず、意図しない不具合やWarningでつまづきました。

今どきAIに聞けば対処療法的には解決できるのかもしれませんが、基本方針としてどうするべきかを腹落ちさせるための一つの観点として

  • undefineddefaultValuesにセットしてはいけない理由
  • nullはセットしてよいのか

といったところを改めてまとめてみました。

想定読者

  • React Hook Formをフォーム管理ライブラリとして触り始めた方、もしくはなんとなく使ってしまっている方
  • UI層以下のundefined、null等の「値がない」の扱いを整理したい方

試した環境

  • React(v19.0.0)
  • TypeScript(v5.7.2)
  • React Hook Form(v7.56.1)

基本:defaultValuesundefinedは🙅‍♂️

まず私がやってしまっていた定番の良くない実装例です。

Edit.tsx
import { Controller, useForm } from "react-hook-form";

type FormValues = {
  name: string | undefined;
  email: string | undefined;
};

export const Edit = () => {
  // User情報を取得する関数
  const user: FormValues = getUser();

  const { register, control, handleSubmit } = useForm<FormValues>({
    defaultValues: {
      name: user.name,
      email: user.email,
    },
  });

  const onSubmit = (data: FormValues) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} placeholder="名前" />
      <Controller
        control={control}
        name="email"
        render={({ field }) => (
          <input {...field} placeholder="メールアドレス" />
        )}
      />
      <button type="submit">送信</button>
    </form>
  );
};

仮にemaildefaultValuesundefinedの際にフィールドに文字を入力するとA component is changing an uncontrolled input to be controlledのWarningが表示されます。

このWarningの意味するところは、React公式ドキュメントにある通りです。

「コンポーネントが非制御から制御に変わろうとしています」というエラーが発生しています。
コンポーネントに value を渡す場合、そのライフサイクルを通じて常に文字列であり続けなければなりません。(日本語訳)

undefinedがダメなことはReact Hook FormのuseFormのページにも書いてありますね。

制御されたコンポーネントのデフォルト状態と矛盾するため、undefined を初期値として渡すことは避けるべきです。(日本語訳)

私ははじめてこれらのWarningや文章を読んだとき「制御・非制御?デフォルト状態?」となった記憶があるのでもう少し深掘ります。

非制御コンポーネントと制御コンポーネント

前提として、フォームの値をReactで管理する方法には非制御コンポーネントと制御コンポーネントという考え方があります。

方法 説明
非制御コンポーネント HTML側に値を保持し、必要に応じてDOM APIを通じてアクセスする方法。
制御コンポーネント フォームの値を常にReactのStateで管理し、イベントハンドラーを通じて更新する方法。

これを理解するために簡単なNG例を見てみましょう。
例えばこれはReact Hook Formを使わない最小限のフォームコンポーネントですが、これはよくない書き方です。

🙅‍♂️
export const TestForm: FC = () => {
  const [name, setName] = useState<string | undefined>(undefined);

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    console.log(name);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">名前</label>
      <input id="name" value={name} onChange={(e) => setName(e.target.value)} />
      <button type="submit">送信</button>
    </form>
  );
};

Reactは初回レンダリング(マウント時)に、そのコンポーネントが制御コンポーネントか非制御コンポーネントかを判定し、Reactが保持するstateと実際のDOMが持つ値がずれないように動作します。

例えば上記コードのようにnameの初期値をundefinedとした場合、<input value={undefined} />となると思います。
コード上はvalueを指定していますが、React的には「明示的なvalueが存在しない」と見なし、ブラウザ(DOM API)が値を管理する非制御コンポーネントとして扱います。

しかし、その後nameAAAのような値に更新され、<input/>valueとして渡されると公式ドキュメントに書かれている通り、今度は制御コンポーネントとして扱われます。つまり「やっぱりstateの値が正なので自分が管理するわ」と宣言するということになります。このとき「あれ?さっきのルールと違うじゃん。筋通してよ!」とWarningが表示されるのです。

React Hook Formを利用する際も、registerを使っていれば非制御コンポーネントとして一貫して取り扱われますが、たとえば冒頭のコードのように、最初はundefinedとしていたinput<Controller/>を通じてvalueに値を渡すと、途中で制御コンポーネントに変更されることになるため先述したWarningが出ます。

シンプルな解決策

簡単な解決方法としては、null または undefined の場合は""にする"といった具合に、明示的に値をセットしておけば良いです。
そうすれば非制御コンポーネントが制御コンポーネントに途中から変わってしまうことはなく、最初から<Controller/>でラップしている部分は最初から制御コンポーネントとして宣言することができます。

🙆‍♂️
/// ...

const { register, control, handleSubmit } = useForm<FormValues>({
    defaultValues: {
      name: user.name ?? "", // null or undefinedなら空文字をいれる
      email: user.email ?? "",
    },
  });

/// ...

defaultValuesnullなら良いのか?

とはいえ、バックエンドの事情などからnulldefaultValuesにいれたほうが楽なケースもあると思います。
ドキュメント内では "undefinedを避けてください" としか明言されていませんが、nullは良いのでしょうか?
React Hook Form内のサンプルコードはたいてい""defaultValuesにセットしていますが...

React Hook Formのメンテナ曰く"InputのdefaultValuesは少なくともネイティブなinput要素においては、""を渡すべきだ"とのことです。どうやら""が良さそうですね。ただnullがよくない背景は明記されていません。

register とネイティブな <input> 要素を使い、defaultValuesnull を指定して挙動を観察してみましょう。

type FormValues = {
  name: string | null;
};

export default function Test() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { isDirty, dirtyFields },
  } = useForm<FormValues>({ defaultValues: { name: null } });

  const name = watch("name");
  const onSubmit = (data: FormValues) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="name">名前</label>
      <input {...register("name")} />
      <p>名前の値: {name} 型: {typeof name}</p>
      <p>isDirty: {isDirty ? "true" : "false"}</p>
      <button type="submit">送信</button>
    </form>
  );
}

文字を少し打ってから消すと、見た目は空欄で同じですがisDirtytrueになります。
型も最初はnullのためobjectでしたが、stringになっていますね。どうしてでしょうか?

少しだけ内部実装を見てみる

シンプルな答えとしては、HTMLのフォーム要素は、ユーザーが入力している限り値がnullになることはなく、常にDOM API上は値が存在している状態だからです。
たとえば入力欄が空でも、フォームの値は""(空文字)であって、nullには絶対なりません。

少し理解を深めるために内部実装を見てみましょう。

先ほどの例にならって({ defaultValues: { name: null } })としたとします。

まず内部的にキャッシュされた_defaulValuesが初期化されます。
ここでは単純に再帰的にdefaultValuesとしてセットされたオブジェクトをコピーして保持しているようです。フィールドがnullのオブジェクトも問題なさそうです。もしdefaultValuesが指定されなければ、空のオブジェクト{}が用意されます。

https://github.com/react-hook-form/react-hook-form/blob/9ffe5cbe3b490ab89633929719edde662f724a7d/src/logic/createFormControl.ts#L133-L136

ユーザーが値を入力するとonChangeハンドラーが実行されます。
その内部でgetFieldValueまたはgetEventValue関数を使用してHTML要素から値を取得し、その値を内部の_formValuesに格納するといった一連の処理がなされます。
この時点で、HTMLのinput.valuenullundefinedを持たない ため、たとえば「値がない」場合は""(空文字)が_formValuesにセットされます。

https://github.com/react-hook-form/react-hook-form/blob/9ffe5cbe3b490ab89633929719edde662f724a7d/src/logic/createFormControl.ts#L726-L761

そして isDirty を判別する関数では、deepEqualdefaultValuesnull_formValues が持つ値を比較します。

https://github.com/react-hook-form/react-hook-form/blob/9ffe5cbe3b490ab89633929719edde662f724a7d/src/logic/createFormControl.ts#L549-L552

最後の行のdeepEqualを実行した結果

deepEqual("", null) // false

と差分が検出されて isDirty と判定されるという仕組みです。

ちなみにundefinedを設定するとすぐにisDirtyを返すので、同様にundefinedは適切ではないことがわかります。

制御コンポーネントとして扱うならnullでも良いがHTML要素が受け取れる形に変更する

<Controller/>を使ってネイティブなHTML要素をラップする場合はどうでしょうか?

import { useForm, Controller } from "react-hook-form";

type FormValues = {
  name: string | null;
};

export default function App() {
  const { control, handleSubmit } = useForm<FormValues>({
    defaultValues: { name: null },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        control={control}
        name="name"
        render={({ field }) => (
          <input
            {...field}
            value={field.value ?? ""}
            onChange={(e) =>
              field.onChange(e.target.value === "" ? null : e.target.value)
            }
          />
        )}
      />
      <button type="submit">送信</button>
    </form>
  );
}

nullを直接<input/>valueにいれず変換を挟めば問題ありません。
(そうしないとReactの仕様上" value prop on input should not be null "と普通に怒られます)

ただし、onChange={(e) => field.onChange(e.target.value === "" ? null : e.target.value)}を設定しないと、空になった際""となりdefaultValuesnull
と一致せず、reset()isDirtyが正しく動作しなくなるので注意が必要です。

ようやく空の値をnullと一貫して扱えそうです。ただ、値の変換など考慮するべき要素が多いですね。可能な限り""のほうが考慮するべき点は少なそうです。

defaultValuesnullとして設定するべきケースもある

一方で、React UIライブラリ(例:MUI、Ant Design、React DatePicker、React Selectなど)では、コンポーネント内部で状態(state)を管理しており、未選択の状態を示すために null を使用する設計が多くあります。
これらのコンポーネントとReact Hook Formを組み合わせる際には、defaultValuesnull を設定するべきです。

例えばreact-datepickerを<Controller/>でラップし、defaultValues""にすると、クリアした際の値としてはnullとなるので、同様に「見た目上は同じ空だが、isDirtytrueになる」という挙動が残ってしまいます。

export default function Test() {
  const { control, handleSubmit, formState: { isDirty }} = useForm<FormValues>({
    defaultValues: {
       //  未選択をnullで持つ
       // (""だとクリアしたときも date フィールドの isDirty が true になる)
      date: null,
    },
  });

  const onSubmit = (data: FormValues) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="date"
        control={control}
        render={({ field }) => (
          <DatePicker
            selected={field.value}
            onChange={field.onChange}
            isClearable
          />
        )}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

まとめ

今まで書いたことをまとめると以下のようになります。

項目 register(非制御) Controller(制御)
値を管理する主体 DOM API Reactのstate
「値なし」を示すdefaultValues ""(空文字) "" または null
undefinedを使った場合 🙅‍♂️ isDirtyが即trueになる 🙅‍♂️ 制御/非制御 切り替わりのWarningが出る
nullを使うべきケース 🙅‍♂️ 空に戻したときnull""の比較になりisDirtytrueになる 🙆‍♂️ nullで値をクリアできるコンポーネント(例:React DatePicker、React Select)

原則""を使いつつ、nullで値をリセットする(またはnullを未選択状態として扱う)UIライブラリのコンポーネントに関してはnullを使うといった方針で考えるのがよさそうです。その前提として

  • 制御コンポーネントと非制御コンポーネント
  • ネイティブなHTML要素が何を受け取れるか

といった知識が正しいフォームの扱いの助けとなると思います。

実運用してみると、Zod等で管理するスキーマとの組み合わせや細かいUIライブラリの事情によりもう少し複雑になると思いますが(そこが辛いのですが)、一つの指針として参考にしてみてください!

We are hiring!

TAIANでは、このような開発・技術・思想に向き合い、未来をつくる仲間を一人でも多く探しています。少しでも興味を持っていただいた方は弊社の紹介ページをご覧ください。

https://taian-inc.notion.site/Entrance-Book-for-Engineer-1829555d9582804cad9ff48ad6cc3605

TAIANテックブログ

Discussion