Hookを書いてSolid.js のリアクティビティを学ぶ

2022/11/30に公開

個人開発で Solid.js にハマっていることねです。

以前作った CharaXiv という「エモクロアTRPG」のキャラクターシートを管理する身内向けのツールを Solid.js + Nest.js で isomorphic に書き直そうというプロジェクトを趣味で細々とやっています。そんな中で React の hooks と似て非なる Solid.js のリアクティビティの仕組みについて実際に useThrottle という Solid.js のシグナルをスロットリング(値がどれだけの頻度で更新されても一定間隔に間引く)する Hook 的なものを作ってみました。Solid.js のドキュメントだとこういうのは "Reactive Utilities" と呼ぶっぽいんですがなんだか言いづらいんでこの記事では Hook と呼んでしまいます。自分で useThrottle を実装してみて Solid.js におけるリアクティビティへの理解が深まったので共有しようと思い立った次第です。

最もシンプルな形

スロットリングしたいシグナルと、シグナルが更新される最小の間隔を渡すとそのとおりにスロットリングされる Hook がこちらです。関数のシグネチャは (source: Accessor<T>, wait: number) => Accessor<T> となっています。Accessor<T> とは Solid.js の型の一つでその実態は type Accessor<T> = () => T と定義されている関数です。この関数を呼ぶことで「現在そのアクセサが持っている値」を取り出すことができます。呼ばれた時に持っていた値が出てくるのでスロットリングは簡単に実装することができます。

import { Accessor, createSignal, onCleanup } from 'solid-js'

export const useThrottle = <T>(
  source: Accessor<T>,
  wait: number,
): Accessor<T> => {
  const [signal, setSignal] = createSignal(source())
  const handle = setInterval(() => setSignal(() => source()), wait)
  onCleanup(() => clearInterval(handle))
  return signal
}

まず createSignal を使って Hook の初期化時に渡された値でシグナルを作ります。このシグナルを wait ごとにその時の source() の値にセットしてあげれば、返された signal は最短でも wait ごとにしか値が更新されません。

次に setSignal を呼び出す setInterval を実行します。React の場合には useThrottle をコンポーネントから呼び出すと再レンダリングの度に setInterval が呼ばれてしまいますが、Solid.js はコンポーネント関数を初期化時に一度しか呼ばないため setInterval が一度しか呼ばれません。呼び出し元が破壊される時に clearInterval を呼べばいいのでそれを実現する onCleanup で実行しています。

waitの変更に追従する

さて、ユースケースとしては上記のシンプルな実装に比べてややニッチですがメインのシグナルだけでなくスロットリングの間隔に対してもリアクティブにしたいというのは要望としてありえるでしょう。これを実現するためには2つ目の引数である wait の型を number | Accessor<number> とし、こちらもシグナルを受け取れるようにしてやる必要があります。加えて、wait のシグナルに変更があった時にのみ setInterval をリセットしたいので少し工夫が必要です。まずは naive に実装してみましょう(理由は後述しますがこれは期待通りに動作しません)。

とりあえず愚直に実装してみる

import {
  Accessor,
  createEffect,
  createSignal,
  onCleanup,
} from 'solid-js'

export const useThrottle = <T>(
  source: Accessor<T>,
  wait: number | Accessor<number>,
): Accessor<T> => {
  const timeout = () => (typeof wait === 'function' ? wait() : wait)
  const [signal, setSignal] = createSignal(source())
  const init = setInterval(() => setSignal(() => source()), timeout())
  const [handle, setHandle] = createSignal(init)
  createEffect(() => {
    clearInterval(handle())
    setHandle(setInterval(() => setSignal(() => source()), timeout()))
  })
  onCleanup(() => clearInterval(handle()))
  return signal
}

扱いを楽にするために wait がアクセサの場合は値を取り出して、数字の場合にはそのまま返す timeout という関数を定義します。そしてスロットリングされたシグナルを source() で初期化して setInterval を呼ぶところまでは同じですが、この時にハンドルを初期値としたシグナルを作ります。ここは let handle = setInterval(...) としてしまう手もあるのですが、パフォーマンスは二の次でより「関数リアクティブっぽい」書き方をするためにあえてこのような書き方をしています。

最も大きな変更点は新しく登場した createEffect(...) で、これは React の useEffect と同じと読み替えて差し支えありません。createEffect に渡したコールバック関数内で参照されているシグナルが更新されるとコールバックが実行されるという仕組みになっています。ここまでの説明を読んで「これダメじゃん」と思われた方がいらっしゃるでしょう。そう、上記のコールバック関数は handle シグナルに依存しているので setHandle を呼んでいるので無限ループに陥ります。そのため createEffect に対して「handle の変更は無視してね」と知らせてあげる必要があります。先述の handlelet で変数宣言してしまえばこんなことにはならないんですが、なんとなく「再代入って嫌だよね」という過激派の血が騒いでしまうので「なんとかする」方向で解決していきます。

createEffect に特定のシグナルだけを拾ったり無視したりさせる方法は2つあります。よりシンプルなのが untrack という変更の追従をしなくなる関数を利用する方法。上記の createEffect の中のコールバックで untrack(handle) とするとその時に handle が持っていた値を返してくれます。もう一つが on という useEffect の deps array のように依存するシグナル指定できる関数です。これを使って on([timeout], ...) と記述して timeout にのみ依存してコールバックを実行するように指定することができます。私個人としてはより記述が直感的な untrack の方が好みなのでそちらを採用しました。

完成版 useThrottle

最終的に完成した Hook はこちら。

import {
  Accessor,
  createEffect,
  createSignal,
  onCleanup,
  untrack,
} from 'solid-js'

export const useThrottle = <T>(
  source: Accessor<T>,
  wait: number | Accessor<number>,
): Accessor<T> => {
  const timeout = () => (typeof wait === 'function' ? wait() : wait)
  const [signal, setSignal] = createSignal(source())
  const init = setInterval(() => setSignal(() => source()), timeout())
  const [handle, setHandle] = createSignal(init)
  createEffect(() => {
    clearInterval(untrack(handle))
    setHandle(setInterval(() => setSignal(() => source()), timeout()))
  })
  onCleanup(() => clearInterval(handle()))
  return signal
}

実際に自分で実装してみたことで Solid.js のシグナルがどのように相互作用するのかのいい勉強になりました。

Discussion