Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

ComponentPropsWithoutRef<'button'> と ButtonHTMLAttributes<HTMLButtonElement> は何が違うのか?

A. 型定義的には同じ *1。HTML要素向けのComponentPropsの実装でXxxHTMLAttributesが使われる、という関係。

結論としては同じものとみなして良いんですが、じゃあどう使い分ければ良いのか? という話ですよね。この話をするにはそもそもComponentPropsが何者なのかを知る必要があります。

ComponentPropsは コンポーネントのプロパティを取得するユーティリティ型

ComponentPropsは、渡されたコンポーネントまたはHTML要素のプロパティを取得するユーティリティ型 です。こんな感じになります↓

const MyComponent = (props: MyProps) => <div />;

type MyComponentProps = ComponentProps<typeof MyComponent>; // MyComponentのプロパティ(MyProps)が得られる
type ButtonElementProps = ComponentProps<'button'>; // button要素に設定できる属性が取得できる

ButtonHTMLAttributesとの違い

ButtonHTMLAttributes も button 要素の属性を表現しています。というか、ComponentProps<'button'> の内部実装はほぼ ButtonHTMLAttributes そのものです。

これは ComponentProps の実装を追いかけるとわかります。まず、 ComponentProps<'button'>JSX.IntrinsicElements['button'] です。

    type ComponentProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = T extends
        JSXElementConstructor<infer Props> ? Props
        : T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T]
        : {};

該当コード

JSX.IntrinsicElements['button']DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> です。

        interface IntrinsicElements {
            // ...
            button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;

該当コード

DetailedHTMLPropsはrefを追加するためのものなので、ほぼ ButtonHTMLAttributes<HTMLButtonElement>ComponentProps<'button'> の実体です。

結論: ComponentProps を使うのが良さげ

このように、実装としてはどちらを使っても同じ効果が得られます。ただし、結論としては、「コンポーネント(HTML要素)のプロパティ(属性)を扱いたいシーンであればComponentPropsを使う」のが良いと思います。ButtonHTMLAttributesを直接使う代わりにComponentPropsを使えば、「button要素のプロパティを扱いたいんだな」とコードの意図が明確に表現できるので。

*1:正確に言えば、ButtonHTMLAttributes にrefが追加されてからrefをOmitしたものがComponentPropsWithoutRef。なので完全に一致しているわけではないが、ユースケース上は全く同じとみなして良いはず

dom-testing-libraryはブラウザ環境でも動く

「getByRoleをブラウザ環境でも使えると嬉しいな〜」と思ってたんですが、普通にdom-testing-libraryはブラウザ環境でも動かせる(実DOM APIと互換がある)みたいです。

import { useEffect } from 'react';
import { screen } from '@testing-library/dom';

export function App() {
  useEffect(() => {
    console.log(screen.getByRole('button', { name: '送信' }));
  });
  return <button type="button">送信</button>;
}

デバッグやE2Eテスト、ログ送信なんかで使えるシーンがあるかもしれません。

listboxをアクセシブルに実装したい

listbox というUIパターンを実装しました。ちょっと凝ったラジオボタンリストみたいなものです↓

仕様

クリック時の挙動はだいたいラジオボタンと同じです。

キーボード操作時がラジオボタンと違っています。ラジオボタンではフォーカス移動と同時に選択が行われますが、このリストボックスではフォーカスを移動させただけでは選択されず、スペースで決定することで初めて選択されます。

実装

実装はこんな感じです。azukiazusaさんの実装を大いに参考にしてます→ 【React】アクセシビリティに考慮したリストボックスを実装する (コンボボックスがない、ステートではなくブラウザのフォーカス機能でフォーカス管理をしているあたりが異なります)

const TinyListbox = ({
  options,
  selectedIndex,
  onChangeIndex,
}: {
  options: string[];
  selectedIndex: number;
  onChangeIndex: (index: number) => void;
}) => {
  const optionsRef = useRef<(HTMLDivElement | null)[]>([]);

  return (
    <div
      role="listbox"
      tabIndex={-1}
      onKeyDown={(e) => {
        const focusedIndex = optionsRef.current.findIndex(
          (ref) => document.activeElement === ref
        );
        if (focusedIndex === -1) return;

        // 上下キーでフォーカスを移動させる
        switch (e.key) {
          case "ArrowDown":
            e.preventDefault();
            optionsRef.current[(focusedIndex + 1) % options.length]?.focus();
            break;
          case "ArrowUp":
            e.preventDefault();
            optionsRef.current[
              (focusedIndex - 1 + options.length) % options.length
            ]?.focus();
            break;
          default:
            break;
        }
      }}
      style={{ display: "flex", gap: "8px", outline: "none" }}
    >
      {options.map((option, index) => (
        <div
          key={option}
          role="option"
          aria-selected={selectedIndex === index}
          tabIndex={selectedIndex === index ? 0 : -1}
          onKeyDown={(e) => {
            // フォーカスされてるときにスペースあるいはエンターされたら選択する
            switch (e.key) {
              case "Enter":
              case " ":
                e.preventDefault();
                onChangeIndex(index);
                break;
            }
          }}
          onClick={() => onChangeIndex(index)}
          style={{
            padding: "4px 8px",
            background: selectedIndex === index ? "#ddd" : "#fff",
            cursor: "pointer",
          }}
          ref={(el) => {
            optionsRef.current[index] = el;
            return () => (optionsRef.current[index] = null);
          }}
        >
          {option}
        </div>
      ))}
    </div>
  );
};

参考

ターミナルでspinnerを表示する

デモ

これを実現するためのshowSpinner関数を作りました。

function showSpinner() {
  const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  let spinnerIndex = 0;
  const spinner = setInterval(() => {
    process.stdout.write(`\r\x1b[2K${spinnerFrames[spinnerIndex++ % spinnerFrames.length]}`);
  }, 80);
  return () => {
    clearInterval(spinner);
    process.stdout.write("\r\x1b[2K");
  };
}

コードはこんな感じです↓

  1. スピナーを表示する
  2. 80ms経つ
  3. キャリッジリターン(\r)でカーソル位置を先頭に戻す
  4. \x1b[2K で現在行の内容をクリア
  5. 次のスピナーを表示する (カーソル位置が先頭に戻ってるので前回のスピナーが上書きされる)
  6. cleanupされるまで動作を繰り返す。

使い方はこんな感じ↓

const cleanupSpinner = showSpinner();
await asyncFn();
cleanupSpinner();

エフェクトイベント関数はuseEffectのdepsに"入れてはならない"

depsに入れなくて良い、ではなく、入れてはならない です。

エフェクトイベント関数をdepsに入れるとレンダリング毎にエフェクトが走る

useEffectEventは レンダリングごとに新しい関数を返します (検証用デモ)。 そのため、useEffect の deps にエフェクトイベントを含めると毎回エフェクトが実行されます(時にはこれによって 無限ループが起きます )

const effectEventFn = useEffectEvent(callback); // レンダリングのたびに新しい関数が返される

useEffect(() => {
  return listen(effectEventFn);
}, [effectEventFn]); // ❌ effectEventFn が入っているので毎回実行される!!

なので、depsにeffectEventFnを入れてはいけません。

useEffect(() => {
  return listen(effectEventFn);
}, []); // ⭕️ depsにエフェクトイベント関数が入ってない

なぜこのような実装になったのでしょうか?

背景: ランタイムでのアサーションとして機能させるため

この意思決定の背景はこのPRが参考になります。

[useEvent] Non-stable function identity by poteto · Pull Request #25473 · facebook/react · GitHub

これによると、「useEffectEventがstableであることに依存したエフェクトを再実行することで、depsにエフェクトイベントを入れてはいけないとユーザーに学んでもらうため 」にunstableに変更したようです。

補足説明: なぜunstableであるべきなのか?

上の説明だけではわかりづらいので、いくらか補足します。

まず、useEffectEventを stableで定義する (初回に作成した関数が常に返される) と、useEffectのdepsに入れても入れなくても同じ挙動になります。 実際、stableで定義されているpolyfillの方は、depsに入れても動きます。

useEffect(() => {
  return listen(effectEventFn);
}, [effectEventFn]); // effectEventFnがstableだったらこれでも問題ない

これに対し、useEffectEventを unstableで定義する (レンダリング毎に新しい関数が返される) と、以下のようになります。

  • useEffectのdepsに入れると毎回エフェクトが走る
  • depsに入れないと更新されてもスキップされる

reactのメンタルモデル的には、useEffectのdepsに入れないで欲しいようです。 そのため、"ランタイムアサーション"が追加されている方が良いという結論になったようです。

まとめ: useEffectのdepsは自分で「選ぶ」たぐいの物ではない

今までのuseEffectのdepsは以下の2種類でした。

  • depsに入れても影響しないもの(refなど)
  • depsに入れるとエフェクトがスキップされるようになるもの

19.2でエフェクトイベント関数が加わったことで、さらに以下が加わりました。

  • depsに入れると不要なエフェクトが走るようになるもの

以前からドキュメントにて useEffectのdepsは 自分で「選ぶ」たぐいの物ではない *1と明言されています。エフェクトイベントもこれに照らし合せた実装が行われたということです。 エフェクトイベントの追加がされた今、改めてこのことを肝に銘じておきましょう。