背景

Lapwing for Beginners の練習ツールを作ってみました。速記キーボードに入門する人にご活用頂けると嬉しいです。

Web アプリ

Plover Drills for Lapwing Theory ← のリンクをクリックしてご覧ください。

デモ (GIF アニメーション)

実画面は以下です:

2026-01-20-lapwing-tools-demo.gif

開発

環境構築 (Vite + Biome)

Vite (viːt/) で TypeScript + React の環境構築をしました。 ESLint と Prettier はアンインストールし、 Biome に置き換えました。

あの煩雑なフロントエンドが、ほぼ 2 つのツールで完結するようになったのは嬉しいです。わがままな環境構築オタクも快適に入門できるでしょう!

Base UI

コンポーネントとしては、 Base UI から以下を拝借しました:

Tree shaking を意識しているそうなので、未使用のコンポーネントはバンドルされないと思います。実際、 dist 以下のバンドルされた JS ファイルは 517 kB と小さめでした。

React

React を使ってみて面白かった点をノートします。

CSS Modules

CSS Module をスコープ付きの CSS/SCSS として使うことができました。

例えば以下の CSS ファイルがあったとします:

.selector {
  width: 800px;
}

React 側からは以下のようにオブジェクトとして使用でき、 (おそらく) ランタイムコストゼロで使用できます:

import styles from './App.module.scss';

export const Selector = (): React.JSX.Element => {
  return (<div className={styles.selector}>Choose your drill!</div>);
};

注意点としては、この styles 変数は型付けされておらず、実行時エラーになる場合があります。型ファイルを自動生成するツールなどもありそうです。

静的に名前解決していないのに、ランタイムコストはゼロになる……?

useState

React (hooks) で状態を持たせるには useState を使います。この値はコンポーネントの初期後、 props が変化しても永続されます。

Props が変化した時に状態を初期化したいとします。この場合は、 props を元にコンポーネントの key を設定すると、 key の変更時に新たなコンポーネントが作成されます:

<Drill
  drillData={drillProps.drillData}
  drillDataIndex={drillProps.drillDataIndex}
+  /* key change = new component (reset state) */
+  key={drillProps.filename}
/>

You Might Not Need an Effect - React

状態は値を保持することにも使用でき、子コンポーネントのデフォルト値を useState で持つことにしました:

const [shuffle, setShuffle] = useState(false);
const [defaultShuffle] = useState(() => shuffle);

useEffect, useLocalStorage

React においては状態を最小化し、状態を元に他の値を計算すべきとされています。これはシリアライズするデータを最小限にし、デシリアライズのコードを書くのと似ています。

したがって、状態とは localStorage に保存する値であると考えると良い近似になるかもしれません。デシリアライズは重い処理も多いので、適宜 useMemo でラップします。

localStorage のような仮想 DOM の外の世界と連携する時は、 useEffect を使えば良いようです:

export const useLocalStorage = <T>(
  key: string,
  fromLocalStorage: (s: string | null) => T,
  toLocalStorage: (v: T) => string | null,
): [T, (newState: T | ((prevState: T) => T)) => void] => {
  const [v, setV] = useState(() => {
    return fromLocalStorage(localStorage.getItem(key));
  });

  useEffect(() => {
    const s = toLocalStorage(v);
    if (s === null) {
      localStorage.removeItem(key);
    } else {
      localStorage.setItem(key, s);
    }
  }, [v, key, toLocalStorage]);

  return [v, setV];
};

以下のように使用しました:

const [shuffle, setShuffle] = useLocalStorage<boolean>('my-app/shuffle', (v) => v === 'true', String);

useReducer

useReducer は複数の状態をまとめ、共通のインターフェース越しに更新できるようにしてくれます。プリミティブな操作から解放されて、比較的宣言的にプログラミングできました。

useDebouncedCallback

速記キーボードは短時間に多数の文字を入力するため、入力文字の正誤判定は、キー入力イベントが納まったタイミングで実施したいです。

このように、多数のイベントが発生した場合に一度だけイベントハンドラを呼び出すことを debouncing と言うようです。

型はボロボロなんですが、 AI が書いてくれた useDebouncedCallback がこちらです:

// biome-ignore lint/suspicious/noExplicitAny: ignore
export const useDebouncedCallback = <T extends (...args: any[]) => void>(callback: T, delay: number) => {
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  return (...args: Parameters<T>) => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  };
};

使い方は以下のようになります:

// 正誤判定を実施する
const onChangeDebounced = useDebouncedCallback((text: string) => {
  if (text.trim() === expected) {
    dispatchState({ type: 'NEXT', length: drillData.length });
  } else if (!matchWord(expected, text.trim())) {
    dispatchState({ type: 'FAIL' });
  }
}, 100); // 100ms delay

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  // テキストは即更新
  const text = e.target.value;
  dispatchState({ type: 'SET_TEXT', text });
  // 正誤判定は debouncing する
  onChangeDebounced(text);
};

use, Suspense

誤入力があった場合には、発音記号を表示します。発音記号は、現在は Free Dictionary API から fetch しています。

この非同期処理の待機中に fallback 要素を表示するため、 useSuspense を使っています。

まとめ

盤石な開発環境でした。特に Biome のリントが強かった点と、 React のドキュメントが良かったです。ほぼ『正解』が用意されていて安心感がありました。

快適なツールができたので、どんどん速記の練習をして行こうと思います。

Thank you! の速記で終わります:

THAUBG