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

4 min read読了の目安(約4000字

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

const Button = styled.button`
  background-color: #f00;
`;

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

import React from "react";
import styles from "./styles.module.css";
// _____________________________________________________________________________
//
export type Props = {
  value: string;
  onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
// _____________________________________________________________________________
//
export const Button = (props: Props) => (
  <button className={styles.btn} value={props.value} onClick={props.onClick} />
);

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

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

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

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

型定義を含めて上記課題を解決するためには、以下の指定で達成することができます。Props に書いてある冗長な型定義は「button」タグにマウスオーバーすれば出てくる情報です(VSCode)。この Props を「button」タグに展開し、本来決定したかった style を className で適用します(tag 毎に Props は異なるので注意します)

import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";
// _____________________________________________________________________________
//
export type Props = React.DetailedHTMLProps<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  HTMLButtonElement
>;
// _____________________________________________________________________________
//
export const Button = (props: Props) => (
  <button {...props} className={styles.btn} />
);

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

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

styled.buttonの様な関数を通して定義された 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.DetailedHTMLProps<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  HTMLButtonElement
>;
// _____________________________________________________________________________
//
export const Button = ({ className, ...props }: Props) => (
  <button {...props} className={clsx(styles.btn, className)} />
);

Ref forwarding を施しておく

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

import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";
// _____________________________________________________________________________
//
export type Props = React.DetailedHTMLProps<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  HTMLButtonElement
>;
// _____________________________________________________________________________
//
export const Button = React.forwardRef<HTMLButtonElement, Props>(
  ({ className, ...props }, ref) => (
    <button {...props} ref={ref} className={clsx(styles.btn, className)} />
  )
);

最後に

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