React Hook Form / defaultValues における「値なし」の扱い方
フロントエンドを担当している三谷です。
弊社の「値がない」を示す基本方針としては、UI層以上では undefined
を扱い、バックエンドに送信する際に null
に変換するという形を取っています。
この設計により「値がない」を表す型が複雑になることなく、APIやデータベースとの整合性も保ちやすくなります。
しかし、undefined
を返す可能性があるデータ取得の関数の返り値をそのままdefaultValues
に渡すと、フォーム管理に利用しているReact Hook Formの挙動と噛み合わず、意図しない不具合やWarningでつまづきました。
今どきAIに聞けば対処療法的には解決できるのかもしれませんが、基本方針としてどうするべきかを腹落ちさせるための一つの観点として
-
undefined
をdefaultValues
にセットしてはいけない理由 -
null
はセットしてよいのか
といったところを改めてまとめてみました。
想定読者
- React Hook Formをフォーム管理ライブラリとして触り始めた方、もしくはなんとなく使ってしまっている方
- UI層以下のundefined、null等の「値がない」の扱いを整理したい方
試した環境
- React(v19.0.0)
- TypeScript(v5.7.2)
- React Hook Form(v7.56.1)
defaultValues
にundefined
は🙅♂️
基本:まず私がやってしまっていた定番の良くない実装例です。
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>
);
};
仮にemail
のdefaultValues
がundefined
の際にフィールドに文字を入力すると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)が値を管理する非制御コンポーネントとして扱います。
しかし、その後name
がAAA
のような値に更新され、<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 ?? "",
},
});
/// ...
defaultValues
はnull
なら良いのか?
とはいえ、バックエンドの事情などからnull
をdefaultValues
にいれたほうが楽なケースもあると思います。
ドキュメント内では "undefined
を避けてください" としか明言されていませんが、null
は良いのでしょうか?
React Hook Form内のサンプルコードはたいてい""
をdefaultValues
にセットしていますが...
React Hook Formのメンテナ曰く"InputのdefaultValuesは少なくともネイティブなinput要素においては、""
を渡すべきだ"とのことです。どうやら""
が良さそうですね。ただnull
がよくない背景は明記されていません。
register
とネイティブな <input>
要素を使い、defaultValues
に null
を指定して挙動を観察してみましょう。
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>
);
}
文字を少し打ってから消すと、見た目は空欄で同じですがisDirty
がtrue
になります。
型も最初はnull
のためobject
でしたが、string
になっていますね。どうしてでしょうか?
少しだけ内部実装を見てみる
シンプルな答えとしては、HTMLのフォーム要素は、ユーザーが入力している限り値がnull
になることはなく、常にDOM API上は値が存在している状態だからです。
たとえば入力欄が空でも、フォームの値は""
(空文字)であって、null
には絶対なりません。
少し理解を深めるために内部実装を見てみましょう。
先ほどの例にならって({ defaultValues: { name: null } })
としたとします。
まず内部的にキャッシュされた_defaulValues
が初期化されます。
ここでは単純に再帰的にdefaultValues
としてセットされたオブジェクトをコピーして保持しているようです。フィールドがnull
のオブジェクトも問題なさそうです。もしdefaultValues
が指定されなければ、空のオブジェクト{}
が用意されます。
ユーザーが値を入力するとonChange
ハンドラーが実行されます。
その内部でgetFieldValue
またはgetEventValue
関数を使用してHTML要素から値を取得し、その値を内部の_formValues
に格納するといった一連の処理がなされます。
この時点で、HTMLのinput.value
は null
やundefined
を持たない ため、たとえば「値がない」場合は""
(空文字)が_formValues
にセットされます。
そして isDirty
を判別する関数では、deepEqual
で defaultValues
の null
と _formValues
が持つ値を比較します。
最後の行の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)}
を設定しないと、空になった際""
となりdefaultValues
のnull
と一致せず、reset()
やisDirty
が正しく動作しなくなるので注意が必要です。
ようやく空の値をnull
と一貫して扱えそうです。ただ、値の変換など考慮するべき要素が多いですね。可能な限り""
のほうが考慮するべき点は少なそうです。
defaultValues
をnull
として設定するべきケースもある
一方で、React UIライブラリ(例:MUI、Ant Design、React DatePicker、React Selectなど)では、コンポーネント内部で状態(state)を管理しており、未選択の状態を示すために null
を使用する設計が多くあります。
これらのコンポーネントとReact Hook Formを組み合わせる際には、defaultValues
に null
を設定するべきです。
例えばreact-datepickerを<Controller/>
でラップし、defaultValues
を""
にすると、クリアした際の値としてはnull
となるので、同様に「見た目上は同じ空だが、isDirty
がtrue
になる」という挙動が残ってしまいます。
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 と"" の比較になりisDirty がtrue になる |
🙆♂️ null で値をクリアできるコンポーネント(例:React DatePicker、React Select) |
原則""
を使いつつ、null
で値をリセットする(またはnull
を未選択状態として扱う)UIライブラリのコンポーネントに関してはnull
を使うといった方針で考えるのがよさそうです。その前提として
- 制御コンポーネントと非制御コンポーネント
- ネイティブなHTML要素が何を受け取れるか
といった知識が正しいフォームの扱いの助けとなると思います。
実運用してみると、Zod等で管理するスキーマとの組み合わせや細かいUIライブラリの事情によりもう少し複雑になると思いますが(そこが辛いのですが)、一つの指針として参考にしてみてください!
We are hiring!
TAIANでは、このような開発・技術・思想に向き合い、未来をつくる仲間を一人でも多く探しています。少しでも興味を持っていただいた方は弊社の紹介ページをご覧ください。
Discussion