制御コンポーネントと非制御コンポーネントを正しく理解し、適切なフォームを実装しましょう
制御コンポーネントと非制御コンポーネントについて書こうと思います。
これら二つは、Reactにおいてフォームを扱うコンポーネントの分類です。それぞれで実装方針が異なります。
制御コンポーネントとは
ユーザーの入力値を state で管理する方法をとっているコンポーネントです。
以下に実装例を記載します。
code: (tsx)
const ControlledForm = () => {
// inputに入力されている値
// inputの値が変わるたびに実行する
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value);
// submit時に実行する
const handleSubmitClick = () => alert(inputValue);
return (
<>
<input type="text" value={inputValue} onChange={handleChange} />
<button onClick={handleSubmitClick}>確定</button>
</>
);
};
実装方法
① 入力値を保持する state を用意する。
② input タグの value 属性と state の値を紐付ける。(状態と画面表示が完全に同期する。)
③ 入力フィールドの change イベントで、現在の入力値を state にセットする。
change イベントは、入力の値が変更された時に発行されるイベントです。
これをトリガーとして state の値をこまめに更新します。
そのため、stateを更新する直前にバリデーションを挟み、場合によっては入力をブロックするなど、リアクティブなフォームを作ることが可能です。
具体的に、制御コンポーネントを使用すると以下のようなフォームの実装が可能となります。
入力中にリアルタイムで validation を行う。
code: (ts)
const handleChange = (e) => {
const value = e.target.value;
// 値が入力されるごとに、以下をチェック
if (value.length > 10) {
alert('10文字以内で入力してください。');
return; // 入力値をブロック
}
// 問題なければ値を更新
setInputValue(e.target.value);
};
入力状態によってボタンの disable など、画面表示を制御する。
code: (ts)
const handleChange = (e) => {
const value = e.target.value;
// 値が入力されるごとに、以下をチェック
if (value.length > 10) {
alert('10文字以内で入力してください。');
// ボタンを無効にする処理
return;
}
// ボタンを有効にする処理
// 問題なければ値を更新
setInputValue(e.target.value);
};
特定の入力形式を強制する(数字のみ・電話番号・郵便番号)
code: (ts)
const handleChange = (e) => {
const value = e.target.value;
// 値が入力されるごとに、以下をチェック
if (!value.match(/^0-9*$/)) { alert('数字で入力してください');
return; // 入力値をブロック
}
// 問題なければ値を更新
setInputValue(e.target.value);
};
デメリット 1
ユーザーが入力欄に 100 文字入力したら、100 回の再レンダリングが発生します。
ですので、この方法(制御コンポーネント)でフォームを実装する場合は、コンポーネントの階層に注意を払い、余計な再レンダリングを起こさないようにする必要があります。
デメリット 2
このように、入力項目が多いと、
https://gyazo.com/857a5e453361c3b05333a8a9af32a519
ファイルが肥大しがちです↓
親コンポーネント
code: (ts)
const FormPage: NextPage = () => {
const handleChangeName = (e: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション処理
setName(e.target.value);
};
const handleChangeNameKana = (e: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション処理
setNameKana(e.target.value);
};
const handleChangeAge = (e: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション処理
setAge(e.target.value);
};
const handleChangePost = (e: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション処理
setPost(e.target.value);
};
const handleChangeAddress = (e: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション処理
setAddress(e.target.value);
};
const handleChangeCompany = (e: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション処理
setCompany(e.target.value);
};
const handleSubmitClick = () => alert(${name}/${nameKana}/${age}/${post}/${address}/${company});
return (
<>
<p>たくさん入力フィールドがある制御コンポーネント</p>
<ControlledFormMulti
name={name}
nameKana={nameKana}
age={age}
post={post}
address={address}
company={company}
handleChangeName={handleChangeName}
handleChangeNameKana={handleChangeNameKana}
handleChangeAge={handleChangeAge}
handleChangePost={handleChangePost}
handleChangeAddress={handleChangeAddress}
handleChangeCompany={handleChangeCompany}
handleSubmitClick={handleSubmitClick}
/>
</>
);
};
子コンポーネント(制御コンポーネント)
code: (ts)
const ControlledFormMulti = ({
name,
nameKana,
age,
post,
address,
company,
handleChangeName,
handleChangeNameKana,
handleChangeAge,
handleChangePost,
handleChangeAddress,
handleChangeCompany,
handleSubmitClick,
}: Props) => {
// inputに入力されている値
return (
<>
名前
<input type="text" value={name} onChange={handleChangeName} />
名前(カナ)
<input type="text" value={nameKana} onChange={handleChangeNameKana} />
年齢
<input type="text" value={age} onChange={handleChangeAge} />
郵便番号
<input type="text" value={post} onChange={handleChangePost} />
住所
<input type="text" value={address} onChange={handleChangeAddress} />
会社名
<input type="text" value={company} onChange={handleChangeCompany} />
<button onClick={handleSubmitClick}>確定</button>
</>
);
};
非制御コンポーネントとは
フォームの入力値を DOM で管理する方法をとっているコンポーネントです。
以下に実装例を記載します。
code: (ts)
const UncontrolledForm = () => {
// input要素を監視するためのオブジェクト
const ref = useRef<HTMLInputElement>(null);
// submit時に実行する
const handleSubmitClick = () => alert(ref.current?.value);
return (
<div>
<input type="text" ref={ref} />
<button onClick={handleSubmitClick}>確定</button>
</div>
);
};
実装方法
① ref オブジェクトを作成し、input 要素 に紐付け、 DOM を監視させる。
② 任意のタイミング(実装例では、ボタンのクリック)で DOM を参照し、入力値を取得します。
入力の度に常に値を取得し続ける制御コンポーネントとは違い、非制御コンポーネントは必要な時に入力値を取得するアプローチを取ります。(大抵の場合「必要な時」というのはサブミットされた時かと思います。)
非制御コンポーネントによるフォームの実装は以下のような特徴になります。
validation は、サブミット時に行うことができる。
入力状態によってサブミットボタンの disable などは制御できない。
制御コンポーネントと比べると自由度が低いように思いますが、メリットもあります。
軽い
非制御コンポーネントは画面の状態と入力状態が同期しないため、再レンダリングが発生しません。
パフォーマンスの面では制御コンポーネントよりも優れていると言えるでしょう。
React 以外のコードに値を簡単に渡せる
例えばフォームだけを React で記載しており、それ以外の箇所を別なフレームワークで管理している場合に役立ちます。
React でないコードからも、DOM にアクセスすることで入力値の取得が可能です。
比較
ほとんどの場合では、フォームの実装には制御されたコンポーネント (controlled component) を使用することをお勧めしています。制御されたコンポーネントでは、フォームのデータは React コンポーネントが扱います。非制御コンポーネント (uncontrolled component) はその代替となるものであり、フォームデータを DOM 自身が扱います。
この記事では、制御・非制御の2パターンをどのような状況下で使用できるのか以下の表にまとめています。
table:table
パターン 非制御コンポ 制御コンポ
1 回限りの値の取得(例:submit 時) ✅ ✅
submit 時のバリデーション ✅ ✅
入力中にリアルタイムでバリデーション ❌ ✅
入力状態によって submit ボタンを disable する ❌ ✅
入力のフォーマットを強制する ❌ ✅
複数フィールドの入力で一つのデータを送る (姓名とか?) ❌ ✅
dynamic inputs(フィールドを増減させられるフォーム?) ❌ ✅
フォームライブラリ
React のフォームライブラリは、現在、formik と react-hook-form が人気のようです。
formik は、他のライブラリと比較してレンダリングが多く、パフォーマンスが劣ると言われていますが、それは formik が制御コンポーネントの仕組みを採用していることに起因します。
それに対し、react-hook-form は非制御コンポーネントの仕組みを採用しています。 高速でパフォーマンスがいいことが公式サイトでアピールされています。
現時点では、react-hook-form が人気のようです。
https://gyazo.com/d88bfef0a566d9771942585f770aaad2
react-hook-form 簡単に紹介
react-hook-form を使えば、非制御コンポーネントによるフォーム実装としつつも、比較表の ❌ を ✅ とすることが可能です。
react-hook-form を用いて、複数入力欄+バリデーションを導入した実装例を以下に記載します。
code: (tsx)
const UncontrolledFormRHF = ({ onSubmit }: Props) => {
const { register, handleSubmit, formState } = useForm<Inputs>();
const errors = formState.errors;
const isDisabled = !formState.isValid;
return (
<form onSubmit={handleSubmit(onSubmit)}>
名前
<input type="text" {...register('name', RULES.name)} />
<span>{errors.name?.message}</span>
名前(カナ)
<input type="text" {...register('nameKana', RULES.nameKana)} />
<span>{errors.nameKana?.message}</span>
年齢
<input type="text" {...register('age', RULES.age)} />
<span>{errors.age?.message}</span>
郵便番号
<input type="text" {...register('post', RULES.post)} />
<span>{errors.post?.message}</span>
住所
<input type="text" {...register('address', RULES.address)} />
<span>{errors.address?.message}</span>
会社名
<input type="text" {...register('company', RULES.company)} />
<span>{errors.company?.message}</span>
<input type="submit" value="確定" disabled={isDisabled} />
</form>
);
};
バリデーションのルール定義
code: (ts)
const RULES = {
name: {
required: { value: true, message: '必須です!' },
maxLength: { value: 5, message: '5文字以内で入力してください!' },
},
nameKana: {
required: { value: true, message: '必須です!' },
maxLength: { value: 5, message: '5文字以内で入力してください!' },
},
age: {
required: { value: true, message: '必須です!' },
maxLength: { value: 5, message: '5文字以内で入力してください!' },
},
post: {
required: { value: true, message: '必須です!' },
maxLength: { value: 5, message: '5文字以内で入力してください!' },
},
address: {
required: { value: true, message: '必須です!' },
maxLength: { value: 5, message: '5文字以内で入力してください!' },
},
company: {
required: { value: true, message: '必須です!' },
maxLength: { value: 5, message: '5文字以内で入力してください!' },
},
};
ファイルも肥大化せず、簡潔に書けて嬉しいなーと感じます。
以下の挙動を確認できるかと思います。
・リアルタイムにバリデーションが効いている
・ボタンの disable 操作もリアルタイムに効いている
フォーム実装に非制御コンポーネントを採用する際の注意点
DOM を消すと不具合の原因となる。
TODO:
何らかの用件で入力欄を非表示にしたい場合、アンマウントさせるのではなく、CSS を使うのがいい。
制御・非制御を下手にまぜない
独自のデザインでチェックボックスを作る時など、
https://gyazo.com/b07412434e823c4c8347a99c27528133
以下のように実装してしまうのは良くありません。
code: (tsx)
const BadCheckbox = ({ onSubmit }: Props) => {
const { register, handleSubmit } = useForm<CheckBoxInputs>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* チェックボックスの実体(cssで隠す) */}
<input type="checkbox" id="aaa" className={style'checkbox-input'} {...register('isChecked')} /> {/* ユーザーに見せるためのチェックボックス */}
<label htmlFor="aaa" onClick={() => setIsChecked(!isChecked)}>
チェックする
</label>
<input type="submit" value="確定" />
</form>
);
};
code: (scss)
.checkbox-input {
display: none;
}
.checkbox-component {
display: inline-block;
width: 20px;
height: 20px;
cursor: pointer;
border-radius: 10px;
&.-checked {
background-color: mediumspringgreen;
}
}
この実装の問題点は、「チェックされたかどうか」という情報を複数箇所で保持していることです。
具体的にいうと、チェックボックスの DOM と、state のisCheckedの二箇所です。これにより、制御・非制御が混ざった状態になってしまっています。
なおかつ、この二箇所の値は同期されていません。何かの拍子にそれぞれの値がズレる可能性があり、「状態と表示が異なるバグ」の要因となります。
ここでいう input の内容、チェックの有無のことを、公式では信頼できる情報源 (source of truth)と書いていましたが、これは一つだけであるべきと考えます。
ちなみに、このような実装をすると React は警告を出してくれます。(これに遭遇したことがきっかけで、制御・非制御を調べ始めました)
https://gyazo.com/ff9f3b9057281bf59adfc21395d8dfff
state のisCheckedの用途は、独自デザインのチェックボックスのクラス名に-checkedのバリアントを持たせることで、見た目を変更することです。
見た目を制御するのみなら、state を使用せずとも CSS を工夫すれば解決できそうです。
以下に、修正を施した非制御チェックボックスの実装を記載します。
code: (tsx)
const GoodCheckbox = ({ onSubmit }: Props) => {
const { register, handleSubmit } = useForm<CheckBoxInputs>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* チェックボックスの実体(cssで隠す) */}
<input
type="checkbox" id="bbb" className={style'checkbox-input'} {...register('isChecked')} /> {/* ユーザーに見せるためのチェックボックス */}
<label htmlFor="bbb">
チェックする
</label>
<input type="submit" value="確定" />
</form>
);
};
code: (scss)
.checkbox-input {
position: absolute;
opacity: 0; // アクセシビリティツリーから削除されないよう、display:noneは使わない
}
input + label > .checkbox-component {
display: inline-block;
width: 20px;
height: 20px;
cursor: pointer;
border-radius: 10px;
}
input:checked + label > .checkbox-component {
background-color: mediumspringgreen;
}
チェック状態は、CSS の擬似クラス「:checked」を用いて把握できます。
(今回の例は、react-hook-formのwatchを使うのでも解決できます)
まとめ