7GUIsで学ぶReact状態管理Jotai | Timer 編 (4/7)


はじめに

この記事は、「GUIプログラミングのベンチマークとして提案された7つの課題を題材に、React状態管理ライブラリのJotaiを学んでみよう」というテーマのJotai学習記事の第四回Timer編です。
完成したコードの解説がメインになります。もしご自身で実装してみたい場合はネタバレになってしまうのでご注意ください。

7GUIsとは

言語やライブラリ系のベンチマークといえば計算速度が評価軸にされることが一般的ですが、この7GUIsはいくつかの指標を軸に7つGUIアプリのお題を用意し、それをベンチマークとして提案しています。
2018年頃に話題となったようで、web系だとReact/MobXやSvelteが実装例として掲載されています。
詳しくはこちら。

https://eugenkiss.github.io/7guis/

7GUIs x Jotai の元ネタ

Jotai作者の さんが過去に取り組んでおり、CodeSandboxで既に実装済みなのでそちらを題材として使わせていただきます。

https://blog.axlight.com/posts/learning-react-state-manager-jotai-with-7guis-tasks/

お題:Timer

(以下DeepL翻訳)

課題:並行処理、ユーザーと信号の相互作用の競合、応答性。
課題は、経過時間eのゲージG、経過時間を数値で示すラベル、タイマー作動中にタイマー時間dを調整するスライダーS、リセットボタンRを含むフレームを作ることである。したがって、S を動かしている間、G の充填量は(通常)直ちに変化する。e ≥ d が成立すると、タイマーは停止する(G は満杯になる)。その後、d を増やして d > e が真になれば、再び e ≥ d が真になるまでタイマーが動き出す。R をクリックすると e がゼロになります。
タイマは、経過時間を更新するタイマプロセスと、ユーザーとGUIアプリケーションのインタラクションが同時に動作するという意味で、並行性を扱っている。これは、ユーザーと信号のインタラクションが競合する場合の解決策も試されることを意味します。また、スライダーの調整を即座に反映させる必要があるため、ソリューションの応答性が試されます。良いソリューションは、信号がタイマーの目盛りであることを明確にし、いつものように、あまり足場を持たないようにします。
Timerは、Crossing State Linesという論文にあるタイマーの例から直接インスピレーションを受けたものです。オブジェクト指向フレームワークを機能的な反応言語に適応させる。

回答コード

解説

Atoms

全atomを確認

  • baseDurationAtom, durationAtom : タイマーの持続する時間を表現します。タイマーなので0秒からスタートしますが、何秒で止まるのかを表します。
  • elapsedTimeAtom, elapsedAtom : 経過時間を表します。exportされているのはread-onlyなelapsedAtomです。
  • timerAtom, startTimerAtom : タイマーの仕事を担います。どちらもexportされていません
  • resetAtom : リセットが必要なのでそれを担います。

タイマーを表現するtimerAtom,startTimerAtomの概要

一番肝心なタイマー部分であり、これまでとは一味違うatomです。
タイマーを表現する方法として、setTimeoutを再帰的に処理して使う方法が採用されています。それを担うのはstartTimerAtomです。
timerAtomはsetTimeoutから払い出されるidや開始時の時間、タイマーを停止した時の為のnull値を持ちます。
主要な箇所を見てみます。

const timerAtom = atom<{ id: number; started: number } | null>(null);

// write-only atom
const startTimerAtom = atom(null, (get, set, action: "start" | "stop") => {
  if (action === "start") {
    ...
    const tick = () => {
      ...
      if (elapsedTime >= get(baseDurationAtom)) {
        set(timerAtom, null); // stop timer
      } else {
        set(timerAtom, {
          started: timer ? timer.started : now - elapsedTime,
          id: setTimeout(tick, 100) // 再度tick()を実行
        });
      }
    };
    tick(); // start timer
  }
  if (action === "stop") {
    const timer = get(timerAtom);
    if (timer) {
      clearTimeout(timer.id);
      set(timerAtom, null);
    }
  }
});

// タイマーを実行
startTimerAtom.onMount = (dispatch) => {
  dispatch("start");
  return () => dispatch("stop");
};

startTimerAtomは、write-only atomです。startとstopを受け取りタイマーを制御しています。
tick関数まわりを見ると分かるように、このatom内でループが起きています。

タイマーを開始させている箇所はstartTimerAtom.onMountになります。onMountは7GUIsシリーズでは初登場です。

onMount is a function which takes a function setAtom and returns onUnmount function optionally.

The onMount function is called when the atom is first used in a provider

setAtom = dispatch は、このAPI(const [val, setVal] = useAtom(someAtom))でいうところのsetValにあたります。
そして、onMountはatomが初めて使われるときに呼ばれる関数です。

https://jotai.org/docs/api/core#on-mount

timerAtomもstartTimerAtomもexportされていませんが、onMountによってタイマーが開始されています。
exportされていないということは、useAtom等を使ってコンポーネントでは使われていないということです。

どこにあるでしょう。
はい、atoms.tsを眺めるとありました。elapsedAtomのread関数内に以下のようにあります。

elapsedAtom
get(startTimerAtom); // add dependency

どういうことでしょうか。ドキュメントにはこうあります。

Dependency is tracked, so if get is used for an atom at least once, the read will be reevaluated whenever the atom value is changed.

https://jotai.org/docs/api/core#atom

read関数においてget(anAtom)とされると、呼ばれたanAtomは再評価されます。
write-onlyなstartTimerAtomだとしても例外ではないようです。onMountはatomが初めて使われる時に実行されるということで、かなりトリッキーですが、これが呼び水となってタイマーがスタートします。

経過を表すelapsedAtom

elapsedAtomはread-only atomとして定義されています。
経過時間を数値で表示する必要があるのでelapsedTime、proportionは経過時間のゲージを描画するために用意されています。
コンポーネントではatomの値をレンダリングするだけ値を操作しない、に限ります。

export const elapsedAtom = atom((get) => {
  get(startTimerAtom); // add dependency
  const duration = get(baseDurationAtom);
  const elapsedTime = get(elapsedTimeAtom);
  let proportion = elapsedTime / duration;
  if (proportion > 1) {
    proportion = 1;
  }
  return {
    elapsedTime: Math.min(elapsedTime, duration),
    proportion
  };
});

タイマーの進む幅を表すdurationAtom

durationが変化したタイミングで条件によってはタイマーを停止する必要があります。(durationが0 = タイマーは進めないので停止)
経過時間の表示なども変更しなければなりません、それらの制御はstartTimerAtomにあるので"start"をsetすることでその処理を呼び出しています。

export const durationAtom = atom(
  (get) => get(baseDurationAtom),
  (_get, set, duration: number) => {
    set(baseDurationAtom, duration);
    set(startTimerAtom, "start");
  }
);

タイマーリセットを担当するresetAtom

こちらはわかりやすいですね。タイマーを止めて経過時間は0に戻し、タイマーを再度スタートしています。

export const resetAtom = atom(null, (get, set) => {
  set(startTimerAtom, "stop");
  set(elapsedTimeAtom, 0);
  set(startTimerAtom, "start");
});

Components

経過時間を表すゲージ

elapsedAtomの値のみを使用します。
elapsedTimeは経過時間の値を、proportionはゲージのwidthを決めるために使われます。

const ElapsedTime = () => {
  const [{ elapsedTime, proportion }] = useAtom(elapsedAtom);
  return (
    <div>
      <span>Elapsed Time:</span>
      <div
        style={{
          width: "100%",
          border: "1px solid gray"
        }}
      >
        <div
          style={{
            width: `${proportion * 100}%`,
            height: "100%",
            backgroundColor: "lightblue"
          }}
        />
      </div>
      <span>{elapsedTime.toFixed(1)}s</span>
    </div>
  );
};

Durationを操作するスライダー

特に説明は不要でしょうか。スライダーは<input type="range" />で表現されています。スライダーのつまみを離さなくともonChangeで値は拾えるのでシンプルです。

const Duration = () => {
  const [duration, setDuration] = useAtom(durationAtom);
  return (
    <div>
      <span>Duration:</span>
      <input
        type="range"
        value={duration}
        onChange={(e) => {
          setDuration(Number(e.target.value));
        }}
        min={0}
        max={30}
        step={0.1}
      />
    </div>
  );
};

おわりに

これまでの7GUIsには登場しなかったonMountであったり、(かなり特殊な使い方でしたが)他のatomを再評価するためにread関数内でget(anAtom)を呼ぶ等がありました。
Write-only atomの中でsetTimeoutを再帰的に使う方法も良い学びとなりました。

特定のAtomを任意のタイミングで再評価する方法に別atomを使う方法は、便利なパターンだと思うので覚えておくと良いかと思います。ドキュメントだとatomWithRefreshに登場します。

https://jotai.org/docs/advanced-recipes/atom-creators#atom-with-refresh

作者ご本人もブログでは「難しくなってきた」、ツイートでは「複雑だよね、試してみて」と言われているように、このコードは解答の1つと捉えてください。
useEffectを使わないように、というのも勉強になります。

Jotai Friendsとは

いちJotaiファンとして、エンジニアの皆さんにもっとJotaiを知ってもらって使ってもらいたい、そんな思いから立ち上げたのがJotai Friendsです。

https://jotaifriends.dev/