Reactコンポーネントの構造

Reactコンポーネントの構造

99

12 分。

Reactコンポーネントの解剖学

多くのチームには「正しい」コンポーネントの姿についての共通認識がありますが、それはたいてい頭の中やコードレビューの中にあり、文章にはなっていません。 ここでは、コンポーネントが置かれるフォルダからエクスポートの行まで、ひとつの具体的なコンポーネント構造を丸ごと書き留めてみます。

フォルダとPropsファイル

コンポーネントは2つのファイルを持つ専用フォルダを占めます:

product-card/
  product-card.tsx
  product-card.props.ts

Propsの型は実装から切り離して置きます。理由は2つあります。

1つ目はサイズです。Pickや交差型、クエリ型から組み立てられた複合的なPropsは簡単に数十行まで膨らみ、同じファイルに置くとコンポーネント本体を覆い隠し始めます。 2つ目はインポートです。コンポーネントの契約はコンポーネント自身だけでなく、テスト、ラッパー、親コンポーネントのPropsからも参照されます。それらすべてには軽い型ファイルだけで十分で、実装ファイルとその依存関係を引き込む必要はありません。

型自体は単にPropsと名付けます。ファイル名がすでにコンポーネント名を含んでいるため、product-card.props.tsの中のProductCardPropsは何も付け加えません。

import type { Props } from './product-card.props';

この命名には欠点もあります。複数のPropsファイルを並べて開くと、型名だけでは区別できず、タブを見るしかありません。また複数のPropsを1つのファイルにインポートすると名前が衝突しますが、asによるリネームで解決できます — import type { Props as CardProps } from ...

Propsは書くものではなく、組み立てるもの

Propsの型がゼロから考案されたフィールドだけで構成されることはほとんどありません。たいていは複数のソース — ネイティブのHTML属性とAPIスキーマから生成された型 — を貼り合わせたものです。

type Props = Pick<ComponentProps<'a'>, 'className'> &
  Pick<ComponentProps<'img'>, 'fetchPriority'> & {
    product: ProductQuery['products'][number];
    onSelect?: ((id: string) => void) | undefined;
  };

className?: stringの代わりのPick<ComponentProps<'a'>, 'className'>は大げさに見えますが、Reactにすでに記述されている型を手で繰り返さずに済みます。また生成されたクエリ型から取ったフィールドはスキーマと一緒に変化します — バックエンドがフィールドをリネームすれば、コンパイラが影響箇所をすべて示してくれます。

もうひとつの細部は、単なる?の代わりの((id: string) => void) | undefinedという書き方です。これはexactOptionalPropertyTypesを有効にした場合に必要になります。このフラグは「フィールドが存在しない」と「フィールドは存在するがundefinedである」を区別し、後者はPropsをspreadで受け渡すときに頻繁に発生します。 明示的な| undefinedがないとそうした呼び出しはコンパイルが通らず、一度長く書くほうが、条件付きのspreadをすべての呼び出し箇所にばらまくよりも簡単です。

そして構文よりも重要な最大のルールは、似たコンポーネントにはそれぞれ狭い型を持たせることです。商品カード、記事カード、レビューカードがデータの構成で異なるなら、それは3つのPropsを持つ3つのコンポーネントであって、フィールドの半分がオプショナルな型を持つ1つのコンポーネントではありません。 rating?author?videoUrl?が何の関連もなく並ぶ型は、もはや契約を記述していません — すべてのケースの合併を記述しているだけです。 これは本質的にSOLIDのインターフェース分離の原則をPropsに適用したものです — コンポーネントは、そのシナリオのひとつだけが必要とするフィールドを宣言すべきではありません。

本体の中の順序

インポートが上にあり、その直後にコンポーネントの宣言が来る — ここに特別なことはなく、面白いのは本体の中です。 本体にも固定されたブロックの順序があり、そのおかげで特定の種類のロジックはファイル全体を読むのではなく、決まった場所で見つかります。

const Dropdown = (props: Props) => {
  // 1. propsの分割代入
  const { onChange, options, value } = props;

  // 2. reactフック
  const [isOpen, setIsOpen] = useState(false);

  const listRef = useRef<HTMLUListElement>(null);

  // 3. 変数
  const selected = options.find((option) => option.value === value);
  const displayValue = selected?.label ?? '選択';

  // 4. 関数
  const handleSelect = (nextValue: string) => {
    onChange(nextValue);
    setIsOpen(false);
  };

  // 5. useEffectとuseLayoutEffectは最後、returnの直前に
  useEffect(() => {
    if (!isOpen) return;

    const handleClickOutside = () => setIsOpen(false);

    document.addEventListener('click', handleClickOutside);

    return () => document.removeEventListener('click', handleClickOutside);
  }, [isOpen]);

  return (/* ... */);
};

export { Dropdown };

分割代入 → フック → 変数 → 関数 → エフェクト。 この順序は恣意的ではありません。後のブロックは前のブロックに依存できますが、逆はできません。変数はフックを使い、関数は変数とフックを使い、エフェクトは上にあるすべてを使います。

逆の順序はJSではそもそも動きません(constは宣言前に使えません)。しかし混在した順序は動いてしまいます。そして読みにくいコンポーネントの大半はまさにこの混在した姿をしていて、ファイル中央のエフェクトの依存関係を理解するために視線を上下に往復させる羽目になります。

useEffectuseLayoutEffectは他のフックと一緒に先頭に置くのではなく、独立した最後のブロックにします。エフェクトはたいてい本体の下方で宣言されるもの — ハンドラや計算済みの変数 — に結び付いており、参照先より先にエフェクトを読むのは不便だからです。

3番目のブロックの変数とは、Propsやステートから計算され、JSXの前に名前を与えられるすべてのものです。

const isHighPriority = fetchPriority === 'high';
const isOutOfStock = product.stock === 0;

狙いは、下のマークアップが「すでに下された決定の列挙」として読めることです。決定が下される場所として読ませてはいけません。JSXの中のproduct.stock === 0という条件は読み手にその場でロジックを解読させますが、isOutOfStockはそうさせません。

バリアントはifではなくオブジェクトで

コンポーネントに外観のバリアントがあると、条件をマークアップ中に散りばめたくなりますが、代わりにバリアントをテーブルで記述するほうが良いです — バリアントがいくつ増えてもCognitive Complexityは増えません。

const VARIANT_CONFIG = {
  primary: 'bg-accent text-white rounded-md',
  outline: 'border border-accent text-accent',
  ghost: 'text-black',
} satisfies Record<NonNullable<Props['variant']>, string>;

テーブルはモジュールレベル、つまりインポートとコンポーネントの間に宣言し、本体の中には置きません。本体の中ではオブジェクトがレンダーのたびに再生成されますが、モジュールレベルではファイルの読み込み時に一度だけ生成されます。

Propsにもステートにも依存しないものは、すべて同じ場所に出します。

JSXに残るのはVARIANT_CONFIG[variant] — バリアントが言及される唯一の箇所です。

末尾のsatisfiesは2つの仕事をします。 第一に、網羅性を検査します — variant型に新しい値を追加してオブジェクトの行を忘れると、コードはコンパイルが通りません。 第二に、const CONFIG: Record<...> = {...}という注釈と違い、値のリテラル型を消しません。値は抽象的なstringではなく、具体的な文字列のままです。

このようなテーブルはオープン・クローズドの原則の日常的な実現です。新しいバリアントはオブジェクトへの1行として追加され、既存のマークアップは変わりません。 逆の例は、本体中にisCompactisFeaturedisInlineのようなフラグが散らばり、JSXの各部分がそれらを異なる組み合わせで検査するコンポーネントです。そのようなコンポーネントは局所的に変更できず、1つのバリアントの修正が他のすべてのマークアップを横切ります。

共通コードはスロットで切り出す

複数のコンポーネントでマークアップの一部が一致すると、切り出したくなります。重要なのは切り出しです — 共通コンポーネントは自分の利用者について知ってはならず、フラグの代わりにスロットを使います。

const CardCover = (props: Props) => {
  const { badge, image, overlay } = props;

  return (
    <div className='relative aspect-square'>
      {overlay}
      {image}
      {badge}
    </div>
  );
};

ある利用者はbadgeに「在庫なし」のラベルを入れ、別の利用者は閲覧カウンターを入れ、3人目は何も渡しません。 CardCoverは在庫のことも閲覧数のことも知らず、新しい利用者が現れても育たず、変更される理由はただひとつ — カバー自体のレイアウトだけです。

ただし切り出しの基準は厳格です。切り出すのは文字どおり一致するものであって、「似ている」ものではありません。 形は似ていても異なる理由で進化していく2つのマークアップは、統合後に互いの邪魔をし始め、半年後には共通コンポーネントの中に条件の絡まりが住み着きます — 切り出しが排除するはずだったまさにそれが。

Next.jsのサーバー/クライアント境界も同じ話です。'use client'はイベントやステートを必要とする具体的なファイルに付けます。プレゼンテーション用のラッパーとインタラクティブなリンクのペアでは、ディレクティブは後者にだけ付き、前者はサーバーでレンダリングされ続けます。

スタイル

クラスはcnユーティリティ(clsxtailwind-mergeのラッパー)で組み立て、各条件はテンプレートリテラルで1つの文字列に貼り合わせるのではなく、個別の引数として渡します。

className={cn(
  'flex flex-col rounded-md bg-white',
  isOutOfStock && 'opacity-60',
  className,
)}

外部のclassNameは最後の引数に置きます。そうするとtailwind-mergeのおかげで、呼び出し側はコンポーネントのスタイルに追記するだけでなく、上書きもできます。

エクスポートは最後に

コンポーネントはconstで宣言し、エクスポートはファイル末尾の独立した行に、常に名前付きで書きます。

const ProductCard = (props: Props) => {
  // ...
};

export { ProductCard };

defaultの代わりの名前付きエクスポートは、コンポーネント名がすべてのインポート箇所で同一になり、自動リファクタリングに耐えるために必要です。宣言に付けるのではなく末尾に置くエクスポートは、ファイルの公開面がひとつの場所で見えるようにするためです。

どちらの取り決めも小さく、単体での利益はわずかです。意味は、それらがどこでも守られていることから生まれます。

何のためにこれをやるのか

上のどの項目も新しくはありません。注目すべきは別のことです — 合わせるとSOLIDの半分をカバーします。太った1つの型の代わりの狭いProps — インターフェース分離、バリアントのテーブル — オープン・クローズド、利用者を知らないスロット — 単一責任。

ただしそれはクラス階層ではなく、形の規律によって達成されます。

すべてのファイルが同じ構造 — Propsは別ファイル、冒頭に分割代入、returnの前にエフェクト、末尾にエクスポート — であれば、50個のコンポーネントを持つプロジェクトでコードを読むのをやめ、認識し始めます。ファイルを開き、目で必要な層を見つけ、直す。

コンポーネントの形はその振る舞いと同じくらい予測可能になり、まさにそれが長期的に時間を節約します。

関連記事

  • SSF-U - フルスクリーン表示の統一規格

    SSF-U規格は、フルスクリーン表示のセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています

    33

    3 分。

  • SSA - アコーディオンの統一標準

    SSA規格は、アコーディオンのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を定めています

    47

    4 分。

  • ウェブサイトにおける悪い慣行

    ウェブデザインにおける致命的なミスの分析。スライダー、自動再生、重いページがコンバージョン率やGoogle・Yandexでの検索順位を低下させる理由

    45

    2 分。

  • SSP - ページネーションの統一規格

    SSP規格は、ページネーションのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています

    55

    3 分。

  • SSPS - プロジェクト構造の統一基準

    SSPS標準は、プロジェクト内のファイルおよびフォルダの構造と命名に関する要件を規定しています

    165

    3 分。

  • SSG - Gitの統一標準

    SSG標準は、Gitのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています

    48

    6 分。

  • すべての記事

ご連絡ください