React コンポーネントの「制御・非制御」を意識しない方法
React でフォームを作るとき「制御・非制御」コンポーネントに関する知識は必須です。デザインシステムを作成するにあたり、どちらを採用するか検討されたこともあるかと思います。
「制御・非制御」コンポーネントの差分を一言でまとめると、次のとおりです。
- 制御コンポーネントはライブラリ(React)が「入力要素の状態」を管理
- 非制御コンポーネントは「入力要素の状態」を DOM 自身が保持
「制御・非制御」コンポーネントと Form ライブラリ
React Hook Form は、非制御コンポーネントを使うことで、少ないコード量で高パフォーマンスの Form 実装が実現できる人気のライブラリです。「非制御コンポーネント」として作成された<Checkbox>
コンポーネントの例を見てみましょう。次の方法で<input type="checkbox" name="test" />
がレンダリングされ、Form の送信準備は完了です。
const { register } = useForm();
<Checkbox {...register("test")} />;
一方「制御コンポーネント」として作成された<Checkbox>
コンポーネントの例を見てみましょう。React Hook Form で制御コンポーネントを扱うとき、<Controller>
コンポーネントやuseController
フックを中継する必要があります。冗長ですが、外部ライブラリなど、デザインシステムがそもそも制御コンポーネントで作られていた場合、選択肢はこちらしかありません。
const { control } = useForm();
<Controller
control={control}
name="test"
render={({
field: { onChange, onBlur, value, name, ref },
fieldState: { invalid, isTouched, isDirty, error },
formState,
}) => (
<Checkbox
onBlur={onBlur} // notify when input is touched
onChange={onChange} // send value to hook form
checked={value}
inputRef={ref}
/>
)}
/>;
例外的に、制御・非制御を併用するケースもありますが、できることなら「非制御コンポーネント」として、デザインシステムを揃えたいところです。しかし、React Hook Form を使用する前提であっても、やむをえない理由で「制御コンポーネント」を作ってしまうことがあります。
見た目の制御に useState を使用している
「やむをえない理由」のほとんどが、見た目の制御を、内部ロジックに委ねていることが原因かと思います。次のようなコードで、チェックされて「いる・いない」を切り替えたことが、誰しも経験したことがあるのではないでしょうか。しかし、制御コンポーネント縛り以前の問題を、このコンポーネントは抱えています。
const Checbox = (props: ComponentPropsWithoutRef<"input">) => {
const [checked, setChecked] = useState();
const ref = useRef(null);
return (
<span>
<input
{...props}
ref={ref}
type="checkbox"
checked={checked}
style={{ display: "none" }}
/>
{/* 状態を参照し、見た目を切り替え */}
<span className={clsx(checked && styles.checked)} />
</span>
);
};
それは、装飾のために邪魔な<input>
をdisplay: "none"
で消している点です。display: "none"
を使用してしまうと、アクセシビリティツリーから要素が除外され、支援技術から操作不能になってしまいます。これは望ましいものではないでしょう。
また、コンポーネントをベースにしてしまうと、Testing Library のgetByRole("checkbox")
で要素を特定できないことはもちろん、toBeChecked
マッチャーも使用できません。後続のテストコードは、苦しいものになることが予想されます。
見た目の制御に JavaScript が必要だったのか再検討する
もし求めているものが「見た目の制御」のみならば、useState
やuseRef
は不要です。<Checkbox>
のような小さいコンポーネントの場合、DOM の状態に委ねたスタイリングを施せば、そもそも JavaScript は不要です。つまり、見た目の制御に「制御・非制御」を気にする必要はないということです。
次の CodeSandbox は、HTML・CSS のマークアップのみで構成されています。<label>
の「Check!」をクリックすると、すこし離れた箇所にある「●」<span>
の色が変わる様子が確認できます。
<div>
<label for="example">Check !</label>
<span class="Example">
<input id="example" type="checkbox" />
<span></span>
</span>
</div>
label {
font-size: 18px;
user-select: none;
cursor: pointer;
}
.Example > input + span::after {
content: "";
display: inline-block;
width: 24px;
height: 24px;
border-radius: 12px;
background-color: antiquewhite;
}
.Example > input:checked + span::after {
background-color: aqua;
}
- 隣接兄弟結合子「+」で、input 直後の span を特定
- 擬似要素「::after」を利用して装飾を施す
- 擬似クラス「:checked」など、状態に応じてスタイルを上書き
という実装をしています。空の<span>
を内包し、隣接兄弟結合子「+」をもって装飾領域を拡張している点がポイントです。擬似クラス「:checked」に相当する、状態起因のセレクターを応用することで、さまざまなスタイルを施すことができます。
Checkbox
独自デザインの<Checkbox>
コンポーネントです。タブキーによる移動・スペースキーによるチェックも操作できます。opacity: 0
の<input>
要素を覆いかぶせており、その要素が反応しています。
Switch
こちらは<Switch>
コンポーネントを想定したものです。<Checkbox>
コンポーネントとの DOM 差分は、role="switch"
ぐらいです。個別に作り込みが必要なのは、CSS のみです。
Textfield
MUI の Text field を真似てみました。1 文字以上入力されている状態にかぎり、placeholder が見出しの位置にアニメーションするよう定義しています。:placeholder-shown
が「0 文字」判定に利用できます。
結論
この CSS 指定が施されたコンポーネントであれば「制御・非制御」どちらのコンポーネントとしても使えます。React コンポーネントの「制御・非制御」を意識しない方法は、React の新しい使い方を覚えるわけではなく、基礎に立ち帰り CSS スタイリングの引き出しを増やすことです。
CSS ソリューションは不問で、CSS Modules でも CSS in JS でも実現できます。マークアップのみなので、当然 React である必要もありません。Atoms のような小さい UI は「DOM 要素のネイティブな状態をたよりにスタイリングすべき」という考えが、筆者の結論です。
Discussion