🧐

useSyncExternalStore の挙動への誤解

2024/11/09に公開

useSyncExternalStore の挙動を誤解していたために外部ストレージをうまく扱えていなかったので、覚書がてら簡潔に。

言いたいこと

  • useSyncExternalStore(subscribe, getSnapshot)getSnapshot が新しいインスタンスを返すときのみ更新され、getSnapshotsubscribe 関数内で購読しているイベントが発火されたタイミングで発火される。
  • すなわち、 subscribe 関数内に再レンダーさせたいタイミングのイベントを記述するだけでは再レンダーされない場合があることに注意。

useSyncExternalStore の用途・使用方法

  • 外部ストレージのカスタムフック作成を補助する React のフック (v18^)
  • 同様のことは今までも useStateuseEffect を用いてできていたが、これによりシンプルに書けるようになった
// https://ja.react.dev/reference/react/useSyncExternalStore#subscribing-to-a-browser-api
import { useSyncExternalStore } from "react";

// オンライン状態かどうかを返すカスタムフック
export function useIsOnline() {
  return useSyncExternalStore(subscribe, getSnapshot);
}

// オンライン状態を返すだけの関数
function getSnapshot() {
  // navigator.onLine: ブラウザーがオンラインかどうかを返す
  return navigator.onLine;
}

// オンライン・オフラインの変化イベントを購読するための関数
// 購読を解除する関数を返す必要がある
function subscribe(callback) {
  addEventListener("online", callback);
  addEventListener("offline", callback);

  return () => {
    removeEventListener("online", callback);
    removeEventListener("offline", callback);
  };
}

経緯

先の例で以下のように、navigator.connection のようなインスタンスも一緒に返したいとする:

function getSnapshot(): [boolean, Geolocation] {
  return [navigator.onLine, navigator.geolocation];
}

これは以下のようなエラーが出る:

useIsOnline.ts:4 Warning: The result of getSnapshot should be cached to avoid an infinite loop Error Component Stack
    at App (App.tsx:9:29)

これを回避するために snapshot をキャッシュするように変更する(参考):

const cache: [boolean, Geolocation] = [navigator.onLine, navigator.geolocation];

function getSnapshot() {
  cache[0] = navigator.onLine;
  cache[1] = navigator.geolocation;

  return cache;
}

こうすると今度は online/offline イベント発火時に useSyncExternalStore が変更された値を返さなくなる。

原因

useSyncExternalStore に渡す subscribe 関数が引数に受け取る callback は以下の様になっている:

function subscribeToStore(fiber, inst, subscribe) {
  var handleStoreChange = function () {
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceStoreRerender(fiber);
    }
  }; // Subscribe to the store and return a clean-up function.

  return subscribe(handleStoreChange);
}

「購読しているイベントが発火したタイミングで、スナップショットが変更されていればレンダリングを強制する」という文脈が読み取れる。

先のキャッシュ化の実装はスナップショットが同じオブジェクトを返してしまうので、購読したイベントが発火してもスナップショットが変更されていないため、useSyncExternalStore が新しい値を返さず、再レンダリングも発生しない。

解決策の例

navigator.onLine が変更された際の navigator.geolocation が欲しいだけなら そもそもあんなカスタムフックを作る必要はない

export function useIsOnline() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);

  return [isOnline, navigator.geolocation];
}

function getSnapshot() {
  return navigator.onLine;
}

function subscribe(callback: () => void) {
  addEventListener("online", callback);
  addEventListener("offline", callback);

  return () => {
    removeEventListener("online", callback);
    removeEventListener("offline", callback);
  };
}

なんにせよ、自分が作成したいフックの更新条件を整理する必要があることに注意。


useReplicant を作成する例

以下ほぼおまけ

この記事は NodeCGuseReplicant を自作した際に困ったための備忘録という面があるため、以下に実装と要点を記述する。

import { useCallback, useMemo, useSyncExternalStore } from "react";
import { IReplicant } from "@/types/schemas";

const defaultValue: IReplicant = { ... };

type SetValueAction<T> = (newValue: T | ((old?: T) => T)) => void;
type ReturnType<TKey extends keyof IReplicant> = [
  IReplicant[TKey] | undefined,
  SetValueAction<IReplicant[TKey]>,
  {
    isLoaded: boolean;
  }
];

const DUMMY = Symbol();

export const useReplicant = <TKey extends keyof IReplicant>(
  key: TKey
): ReturnType<TKey> => {
  // replicant 自体はリアクティブでなくて良い
  const replicant = useMemo(() => {
    return nodecg.Replicant<IReplicant[TKey]>(key, {
      defaultValue: defaultValue[key],
    });
  }, [key]);

  // replicant.status === "declared" になると DUMMY ではなく replicant.value を返すようになり、
  // getSnapshot() の返すオブジェクトが変更された判定になる
  // -> 再レンダリングが走る
  //
  // DUMMY ではなく undefined だと、 replicant.value がもともと undefined だった場合に
  // getSnapshot() の返すオブジェクトが undefined のまま
  // -> 再レンダリングが走らない
  const getSnapshot = () => {
    return replicant.status === "declared" ? replicant.value : DUMMY;
  };

  const subscribe = (callback: () => void) => {
    replicant.on("change", callback);

    return () => {
      replicant.removeListener("change", callback);
    };
  };

  // DUMMY をわざわざ採用している弊害が出ている
  const storedValue =
    useSyncExternalStore(subscribe, getSnapshot) === DUMMY
      ? undefined
      : replicant.value;
  const isLoaded = replicant.status === "declared";

  const setValue: SetValueAction<IReplicant[TKey]> = (newValue) => {
    if (typeof newValue === "function") {
      replicant.value = newValue(storedValue);
    } else {
      replicant.value = newValue;
    }
  };

  return [storedValue, setValue, { isLoaded }];
};

大事な点はコメントに記述しているが、

  • replicant.status === "declared" になると DUMMY ではなく replicant.value を返すようになり、getSnapshot() の返すオブジェクトが変更された判定になる
    • -> 再レンダリングが走る
  • DUMMY ではなく undefined だと、 replicant.value がもともと undefined だった場合に getSnapshot() の返すオブジェクトが undefined のまま
    • -> 再レンダリングが走らない
  • const storedValue = ...DUMMY をわざわざ採用している弊害が出ている
    • 回りくどいだけではある

useEffectuseState の組み合わせより記述量が増えるが、比較的ロジックはシンプルに書けてわかりやすい(多分)

追記

Replicant.revision を使っても良い気がした

  • Replicant.revision は、データの版がインクリメンタルに記録される変数
  • replicant.status === "declared" or 初期状態は 0
const getRevision = () => {
  return replicant.status === "declared" ? replicant.revision : undefined;
};

const subscribe = (callback: () => void) => {
  replicant.addListener("change", callback);

  return () => {
    replicant.removeListener("change", callback);
  };
};

const revision = useSyncExternalStore(subscribe, getRevision);

const storedValue = revision !== undefined ? replicant.value : undefined;
const isLoaded = revision !== undefined;
GitHubで編集を提案

Discussion