Panda Noir

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

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と明言されています。エフェクトイベントもこれに照らし合せた実装が行われたということです。 エフェクトイベントの追加がされた今、改めてこのことを肝に銘じておきましょう。

【考察】use APIの内部構造を予想してみた

※この記事はuse APIへの自分の理解と、実際にコードを起こして実験した結果を記しただけの 考察記事 です。正しさは保証されていません。「多分こういうマインドセットを持ってるとええんやな〜」くらいの温度感でお楽しみください。

導入: Throw a Promiseテクと use API はどういう関係なのか?

react 19から導入されたuse APIによって、reactは自然にPromiseを扱えるようになりました。 しかし、react18まではPromiseをthrowすることでレンダリングを中断するやり方も提供されていました。2つはユースケースがとても似通っていて混乱します。

このユースケースが似た2つ(use APIとThrow a Promise)の関係は、uhyo さんが記事でわかりやすくまとめています。

ちなみに、Suspenseを前提とする以上、useの中身は良く知られた「Promiseをthrowする」実装になっています。ただし、useは生でPromiseをthrowするのではなく、Suspense ExceptionというErrorオブジェクトでラップしてthrowするようです。これは開発者をなるべく混乱させないための配慮でしょう。

use API|React 19の新機能まるわかり

つまり、Throw a Promise を使った洗練された API が use API とのことです。また、「"Throw a Promise"をdeprecatedにしてuse APIを推奨していこう」というissueも立てられています (当該issue)。このことからも、今後は開発者が Throw a Promise を自らする必要はなくなっていくと思われます。

でも、use API が内部で Promise を throw しているとはどういうことなんでしょうか? 実際に書けるんでしょうか? そう思ってトライしてみたのが本記事になります。

use API を自作してみる

use API の要件としては以下になるはずです。

  • Promiseを受け取る
  • Promiseがresolveしたらその値を返す
  • PromiseがまだresolveされてなかったらPromiseをthrowする

これを非async関数で実装するには、resolveされた値を保存する記憶領域を外部に確保する必要があります。幸いにもJSにはWeakMapというものがあるので、それを使いましょう。

const dataMap = new WeakMap(); // resolveされた値を保存しておく場所
const use = (promise) => {
  promise.then((data) => {
    dataMap.set(promise, data); // resolveされたら保存する
  });
  const data = dataMap.get(promise);
  if (!data) throw promise; // dataがまだない=resolveされてなかったらpromiseをthrowする
  return data; // dataがある=resolve済みの場合はdataを返す
};

これを使って実際に実装してみたコードがこちらになります。

ちゃんとreact19のuse APIと同じような動きが実装できています。

(uhyoさんがいってる「Suspense Exceptionでラップしてる」の部分はよくわかりませんでした。有識者求ム)

まとめ: 今後は use API だけ使えば良さそう

ここまでの実験によって、throw a Promiseによってレンダリングの中断・再開を実装するテクニックは、use API によってより洗練されたインターフェイスとなったことが分かりました。

use API で大体のユースケースはカバーできると思うので、今後は基本的に use API だけ使えば良さそうです。