🎨

atoms の「制御・非制御」をどう作るのか

2021/03/08に公開

本稿では React で atoms を作る際「制御・非制御」どちら前程に作るべきか?という課題についてを考察します。

「制御・非制御」は atoms では決定しない

なぜ決定しないのかは「ライブラリ選定に縛られないため」につきます。Form 関連ライブラリはたくさん選択肢がありますが、この選定が atom に影響するのはあまり良いパターンではありません。

「制御・非制御」は atoms では決定しないといっても、正確には以下の「A・B」のうち 「A」で決定しない という意味です。以下「A・B」の様にきちんと分けて構成することで「A」は「制御・非制御」に囚われず、再利用することが出来ます。

  • A. 【"style"決定層】CSS のみが適用された element 集
  • B. 【"制御・非制御"決定層】A を用いて、再度小さい atom を構築する

CSS in JS 以外での定義

CSS in JS の場合「A」を作るためには次の様な Input 定義をします。これと同等の Component を CSS in JS 以外(例:CSS Modules / Tailwind)で実装することを目指します。「style だけを適用したいが、その他の Props を tag 本来の Props としたい」というものです。

const Input = styled.input`
  border-color: #f00;
`;

はじめに思いつくのが次の様な指定です。さて、この Component は何がよくないでしょうか?

import React from "react";
import styles from "./styles.module.css";

export type Props = {
  value: string;
  onChange?: (event: React.MouseEvent<HTMLInputElement, MouseEvent>) => void;
};

export const Input = (props: Props) => (
  <input
    className={styles.input}
    value={props.value}
    onChange={props.onChange}
  />
);

この Component は汎用パーツであるにも関わらず、入力値(Props)が限定的です。実装が進むにあたり、次の様な要望が発生する可能性が非常に高いです。

  • onMouseEnter などのイベントハンドラーを追加したい
  • data-*属性を追加したい
  • aria-*属性を追加したい

要望が発生するたびに Props を定義を追加するのは望ましい姿ではありません。この層で決定したいのは「style」だけのはずで、その他の Props はタグ本来の Props を受付けたいはずです。

タグ本来の Props を適用できる様にしておく

型定義を含めて上記課題を解決するためには、以下の指定で達成することができます。この Props を「input」タグに展開し、本来決定したかった style を className で適用します(tag 毎に Props は異なるので注意します)

import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";

export type Props = React.ComponentPropsWithoutRef<"input">;

export const Input = (props: Props) => (
  <input {...props} className={styles.input} />
);

この書き方であれば、props.childrenも適用されます。

className をマージできる様にしておく

styled.inputの様な関数を通して定義された Component は className を与えることで、Component 自身に付与されている指定のほか、親から与えられた className をマージすることが出来ます。この挙動も再現してみます。

clsxの様な className ヘルパーを追加しておけば、親 Component から props として指定された className もマージすることができます。

import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";

export type Props = React.ComponentPropsWithoutRef<"input">;

export const Input = ({ className, ...props }: Props) => (
  <input {...props} className={clsx(styles.input, className)} />
);

Ref forwarding を施しておく

さて、これで完成の様に見えますが、この状態では掲題の「制御・非制御」切替時に問題がおこります。refは Props には含まれないめ、正しく動作しません。そこでReact.forwardRefで Ref forwarding を施しておきます。これでようやく「style のみ決定する」styled.inputの様な atom が完成しました。

import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";

export type Props = React.ComponentPropsWithoutRef<"input">;

export const Input = React.forwardRef<HTMLInputElement, Props>(
  ({ className, ...props }, ref) => (
    <input {...props} ref={ref} className={clsx(styles.input, className)} />
  )
);

最後に

今回の話は、CSS Modules や Tailwind で直面する特有課題かと思います。CSS in JS では、styled.inputなどの関数を通し、すでに Ref forwarded な Component を得ることができるからです。Next.jsLink子 Component にマウントする anchor Component も Ref forwarding が必要だったりするので、atoms はあらかじめ Ref forward を施しておくと良いかもしれません。

Discussion