React で毎フレーム描画する関数を繋ぎこむパターン
React の枠組みに順当に則ってタイマーを描画したりしようとするとuseEffect で setInterval ないし requestAnimationFrame を発火し、その中で setState を呼ぶなどのような書き方になります。
const TimerWithSetState = () => {
const startAt = useMemo(() => performance.now(), []);
const [time, setTime] = useState(startAt);
useEffect(() => {
let id: number;
const loop = () => {
setTime(performance.now() - startAt);
id = requestAnimationFrame(loop);
};
id = requestAnimationFrame(loop);
return () => cancelAnimationFrame(id);
}, []);
return <div>{time.toFixed(2)}</div>;
};
別にいいのですが、この実装は毎フレーム React が setState を呼び、そのたびに Timer コンポーネントを再レンダリングするというような挙動になります。
まあフレームワークの思想としては健全なのですが、タイマーの表示値を更新するだけなのに React に任せるのは無駄な感じがあります。
ゲームのような複雑な状態を扱いつつ canvas に毎フレーム画を描画するとか、動画パケットを受信しながら VideoFrame を描画したりといったことは React に任せなくても良い気はします。
以下のように実装を変更することで、タイマーの表示値の更新を React の枠組みの外で実行できます。
const TimerWithTextContent = () => {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (divRef.current === null) {
return;
}
const startAt = performance.now();
const element = divRef.current;
let id: number;
const loop = () => {
element.textContent = (performance.now() - startAt).toFixed(2);
id = requestAnimationFrame(loop);
};
id = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(id);
element.textContent = "";
};
}, []);
return <div ref={divRef} />;
};
ref 経由で textContent に直接値を差し込んでいます。
適当に下のようなコードで描画時の関数呼び出しの様子を見てみます。
(真面目な計測は各々におまかせします)
const [checked, setChecked] = useState(false);
return (
<>
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span>{checked ? "useState" : "textContent"}</span>
</label>
{checked ? <TimerWithSetState /> : <TimerWithTextContent />}
</>
);
<TimerWithSetState /> の一描画あたりの関数呼び出しの様子:

<TimerWithTextContent /> の一描画あたりの関数呼び出しの様子:

とりあえず <TimerWithTextContent /> のほうが無駄は少ないですね、というくらいですかね……。
また canvas に外からレンダリングプロセスをフックするには以下のようにします。
transferControlToOffscreen() で OffscreenCanvas を転送する事もできます。
type Props = {
renderer: (ctx: CanvasRenderingContext2D | null) => void;
};
export const CanvasRenderer = ({ renderer }: Props) => {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (divRef.current === null) {
return;
}
const element = divRef.current;
const canvas = document.createElement('canvas');
element.appendChild(canvas);
let id: number;
const loop = () => {
renderer(canvas.getContext('2d'));
id = requestAnimationFrame(loop);
};
id = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(id);
element.removeChild(canvas);
};
}, [renderer]);
return <div ref={divRef} />;
};
こんなかんじで、自分の好きな描画関数を繋ぎこむ事ができます。
あんまり出番はないかもしれないですが……。