🎆

React コンポーネントの「制御・非制御」を意識しない方法

2022/08/13に公開

React でフォームを作るとき「制御・非制御」コンポーネントに関する知識は必須です。デザインシステムを作成するにあたり、どちらを採用するか検討されたこともあるかと思います。

https://ja.reactjs.org/docs/forms.html#controlled-components
https://ja.reactjs.org/docs/uncontrolled-components.html

「制御・非制御」コンポーネントの差分を一言でまとめると、次のとおりです。

  • 制御コンポーネントはライブラリ(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 が必要だったのか再検討する

もし求めているものが「見た目の制御」のみならば、useStateuseRefは不要です。<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