はじめに
今回はReactの制御コンポーネントと非制御コンポーネントについて紹介します。
Reactのコンポーネントを作成していく上で、Vueの場合とは違い、レンダリングのタイミングをより細かく把握しておく必要があります。
その際に重要になってくるのが制御コンポーネントと非制御コンポーネントの違いです。
現在の公式ドキュメント上の制御コンポーネントと非制御コンポーネント
現在の公式ドキュメントを見ると、制御コンポーネントと非制御コンポーネントの違いが以下のように書かれています。
- 制御コンポーネント
- 重要な情報がローカルstateではなくpropsによって駆動される
- 親コンポーネントがその振る舞いを完全に指定することができる
- 非制御コンポーネント
- ローカルstateを持つコンポーネント
- 親コンポーネントが振る舞いを指定することができない
非制御コンポーネントは、propsが少なくて済むので簡単に利用できる反面、柔軟性がないというデメリットがあります。 一方、制御コンポーネントは、propsが多くなるので、より複雑なコンポーネントを作成することができます。
旧公式ドキュメント上の制御コンポーネントと非制御コンポーネント(今回の説明対象)
今回の記事で説明するのは、旧公式ドキュメント上の制御コンポーネントと非制御コンポーネントです。
旧公式ドキュメントに書かれているフォームの文脈での制御コンポーネントと非制御コンポーネントの違いを簡単にまとめると以下のようになります。
- 制御コンポーネント
- フォームデータの”信頼できる唯一の情報源(single source of truth)“をReactのstateで管理する
- つまりUIは常にstateを反映
- 即時バリデーション、入力マスク、依存フィールドの連動が得意
- 非制御コンポーネント
- フォームデータの”信頼できる唯一の情報源(single source of truth)“をDOM要素で管理する
- 必要な場合はrefを使ってDOM要素を参照する
現在のReact公式ドキュメントでは独立した説明ページはなく、<input>やrefの節などに非制御相当の内容が書かれています。
どちらが正しい、といったものではなく、Reactのコンポーネント文脈、フォームの文脈によって若干定義範囲が異なるものという認識です。
制御コンポーネントと非制御コンポーネントで変わるレンダリングのタイミング
制御コンポーネントでは、入力のたびにonChangeが発火し、親コンポーネント、もしくはコンポーネント内で管理しているstateを更新します。Reactは状態が更新されたタイミングで再レンダーが行われ、DOM反映というループが走ることになります。
依存するUI、つまりエラーメッセージや活性制御なども即時反映されます。
一方、非制御では値はDOMが保持し、タイピング中はReactの再レンダーは原則発生しません。フォーム送信、refでの明示的な読み取り、watchでの管理など必要な箇所だけ更新されることになります。制御コンポーネントと比べるとレンダリング回数が少なくなるためパフォーマンスが向上する傾向にあります。
ただし、React18で並行レンダリングが導入されたりと制御だからといってパフォーマンスが極端に悪いわけではなくなっています。大規模、高頻度の入力であれば体感できるレベルの差が出ることもありますが、小~中規模の場合は差が小さい場合もあります。
※ここでいう renderはReactの文脈でたびたび登場しますが、React初学者には馴染みのない言葉かもしれません。ここでは、Reactが管理している関数コンポーネントを実行し、React要素(仮想DOM相当の構造)を生成する段階のことを指します。このタイミングではまだ実DOMに反映されているわけではなく、レンダー後のcommitフェーズで実DOMに反映されることになります。
広義のレンダリングの場合は実DOMへの反映を含めることもありますが、ここではDOM反映を分けて説明しています。
React Hook Formはどっち?
Reactにおけるフォーム実装の主流な選択肢として、React Hook Formがあります。
React初学者にも選択されがちなライブラリですが、React Hook Formは非制御中心です。
ネイティブ入力はregisterでrefを結びつけ、入力中はReactを無駄に再レンダーさせないような設計になっています。
値が必要になった時だけwatch/useWatchで購読することで描画を更新します。これにより大規模フォームでもパフォーマンスが向上し、バリデーションなどはresolverを利用することでzodなどのバリデーションライブラリと統合して疎結合に実装が可能です。
かつ、必要な箇所はControllerを利用することで制御コンポーネントとして振る舞うようになっています。
つまり、「基本的には非制御、必要な箇所は明示的に制御を採用」という設計思想になっています。
なお、React Hook Formには設計上問題がある部分もあるため、理解した上で選択するようにしましょう。特に型安全性や、これまで説明した非制御前提によるSSOTの崩壊などがあります。アプリ全体のSSOTを上位stateにおく設計と比べるとRHFはフォーム側にSSOTを寄せるため、同一データを上位で扱いたい場合はうまく同期する必要があります。SSOTというのはSingle Source of Truthの略で、信頼できる唯一の信頼源と訳されることもあります。ここでは、フロントエンドの情報源として利用できるものを一つにするということです。利用しているUIコンポーネントの設計と整合性をとる必要があるため、適切にControllerを使う必要があります。
実装で気をつけるアンチパターン
1)valueを渡しているのに、onChangeを渡さない
- 症状: 入力が反映されず、事実上読み取り専用になる
- 原因:
valueを渡した時点で制御コンポーネントになるため、更新ハンドラがないと値が変更できない。 - 対処: 制御にする場合と非制御にする場合をしっかり分ける
- 制御にする場合、
valueとonChangeをセットで渡す - 非制御にする場合、
defaultValueのみにしてvalueは渡さない。RHFの場合はregisterを使う
- 制御にする場合、
2)レンダー途中で制御、非制御を行き来する
- 症状: コンソールに “A component is changing an uncontrolled input to be controlled” 警告が出る。入力が消えたり、反映されない。
- 原因: 同一フィールドで value の有無が切り替わると React の仮定が崩れる。
- 対処: どちらかに揃える。制御で通すなら初期値は ”(undefined を避ける)、非制御なら常に defaultValue。
3) 同じフィールドを「親は制御」「子は非制御」で二重管理する
-
症状: 表示値と送信値がズレる/どちらが正か分からない。
-
原因: SSOT(唯一の情報源)が分裂し、更新タイミングが競合する。
-
対処: 片方に統一する。
- 親で制御:
value/onChangeを親が一元管理。 - RHF で非制御:
registerで管理し、親はonSubmitで収集。
- 親で制御:
4) key の付け替えで意図せず再マウントされる
- 症状: 入力が消える/フォーカスや IME が切れる。
- 原因:
key変更は別要素扱いになり、内部 state が初期化される。 - 対処: 意味のない
keyを外す。リセット目的なら **RHF のreset()/resetField()*- や状態初期化関数を使う。
5) すべてのタイピングを上位 state にリフトアップ(過度な制御)
- 症状: 大規模フォームでカクつく/再レンダー波及。
- 原因: 入力ごとに親が再レンダーし、子も巻き込まれる。
- 対処: *非制御 + 局所購読- に寄せる。RHF の
registerを基本とし、必要箇所のみControllerで制御化。
6) RHF で同一フィールドに register と Controller を併用
- 症状: 値・バリデーションが二重発火/不整合。
- 原因: 同じ
nameに複数のデータソースが紐づく。 - 対処: 役割分担を厳守。ネイティブ要素は
register、サードパーティ制御入力はController。
7) カスタム入力コンポーネントが ref を正しくフォワードしない
- 症状:
registerしてもフォーカスや値取得が不安定。 - 原因: RHF は
refで要素を把握するが、forwardRef未実装で到達しない。 - 対処:
forwardRefを実装し、実 DOM 要素(<input>等)にrefが届くようにする。
8) defaultValue と value を同時に指定する
- 症状: 初期値が効かない/警告が出る。
- 原因: 制御(
value)が優先され、defaultValueは無視される。 - 対処: どちらか片方にする。制御なら
valueのみ、非制御ならdefaultValueのみ。
9) RHF の watch を乱用(コンポーネント全域で購読)
- 症状: 逆に再レンダー過多で重くなる。
- 原因: 広域購読により小さな変更でも全体が再描画。
- 対処:
useWatch({ name })で必要フィールドのみ局所購読。派生値はuseMemoで最小化。
10) 送信後に *再フェッチ- と *楽観的更新- を同時に行う
- 症状: 値が一瞬戻る/チラつく/二重更新。
- 原因: 反映順の競合とキャッシュの競合。
- 対処: 方針を一つに統一。基本は再フェッチで真実に寄せ、重い API のときのみ楽観的更新+整合チェック。
11) 表示専用の派生値まで state に保持する
- 症状: 不要な同期・再レンダー・不一致。
- 原因: 冗長な state は同期コストを生む。
- 対処: 元データから都度算出(
useMemo)。どうしても必要な最小限のみ state 化。
12) フォームのリセット目的でコンポーネントを再マウントする
- 症状: 重い/スクロールやフォーカスが飛ぶ。
- 原因: 再マウントは DOM・RHF の全資源を作り直す。
- 対処: RHF の **
reset()/resetField()*- を使い、UI を保ったまま値だけ初期化。
