monologue

クズエンジニアの独白

SWRをローカルの状態管理で使うときの注意

下記の記事でSWRでグローバルstateを管理する方法が気になった。

zenn.dev

上記を元にカスタマイズしつつ、検証しつつ遊んでみる。

まずはuseSWRを使った特定のkey専用のロジックを定義。

export const useSWRSample = (
  initailData: string
): { data: string | undefined; mutate: (updateData: string) => void } => {
  const { data, mutate } = useSWR("testKey", null, {
    initialData: initailData,
  });

  return { data: data, mutate: mutate };
};

使いたいコンポーネントで呼び出し、初期化。

const { data, mutate } = useSWRSample("initaial data");
console.log(data); // initaial data

mutateで値を更新

<button
        onClick={() => {
          mutate("updated data");
        }}
      >
console.log(data); // updated data

上記の値を別のコンポーネントでの読み込みは参考記事では、単純なuseSWRを使っていたので真似する。 SWRの性質を利用してkeyさえあっていれば同様の値を読める。

const { data } = useSWR("testKey", null);
console.log(data); // updated data

ただ、上記では問題があり、更新後のupdated dataは読み込めるが、初期値のinitial dataは読み込めずundefinedになる。 多分これは上記2つのコンポーネントが同ページで読み込まれたときに、初期化処理の前に別コンポーネントで読み込みをするため。 なので、その後mutateすると、同期されて2つのコンポーネントupdated dataとなる。 その証拠に、しれっと参考記事では下記のようにundefinedの際の対策がされている。

const SampleCount = () => {
  const { data: count } = useSWR('count', null);
  return <div>{count || 0}</div>;
};

{count || 0}の部分で、countがfalsyだった場合は初期値の0をセットしている。あまり書き方として筋はよくなさそう。

ちなみに、このままSPA遷移でページを移動しても、値は保持され続ける。 いわゆるstoreのようにグローバルstateとして扱えるイメージ。 storageと組み合わせた半永続化の術もあるかもだが、上記の状態ではページ読み込みすると値が破棄される。


似たような別記事があったので見てみる

zenn.dev

この記事ではuseStaticSWRのように、SRWの第2引数をnullに設定したカスタムのhooksを作っている。 これを使えばSWRでローカルでの状態管理を使える汎用的なものになっている。

https://github.com/webev-dev/webev-front/blob/0c7de95ab36e13a6c9a1851473fadabe3e817e9a/src/stores/use-static-swr.tsx

import useSWR, { Key, SWRResponse, mutate } from 'swr';
import { Fetcher } from 'swr/dist/types';

export const useStaticSWR = <Data, Error>(key: Key, updateData?: Data | Fetcher<Data>): SWRResponse<Data, Error> => {
  if (!updateData) {
    mutate(key, updateData);
  }

  return useSWR(key, null, {
    revalidateOnFocus: false,
    revalidateOnReconnect: false,
  });
};

そして各状態毎にuseStaticSWRを利用した、専用のhooksを作っている構成。例えば下記のように。

export const useOgpCardLayout = (initialData?: OgpLayoutType): SWRResponse<OgpLayoutType | null, Error> => {
  return useStaticSWR<OgpLayoutType | null, Error>('ogpCardLayout', initialData);
};

https://github.com/webev-dev/webev-front/blob/master/src/stores/contexts.tsx

前記事でも一番気になった、初期化後に各コンポーネントで状態を同期する方法を調査。

useStaticSWRを利用した、専用のhooksは3種類あるので、それぞれ調査。

まずuseOgpCardLayoutは、下記のようにコンポーネント読み込み初期時にmutateを実行している。 この処理によって同期している気がする。

  useEffect(() => {
    setIsEnableReadFromClipboard(retrieveValue('isEnableReadFromClipboard') === 'true');
    mutateOgpCardLayout(retrieveValue<OgpLayoutType>('cardLayout'));
  }, []);

https://github.com/webev-dev/webev-front/blob/0c7de95ab36e13a6c9a1851473fadabe3e817e9a/src/components/domain/User/molecules/PersonalDropdown/PersonalDropdown.tsx#L32-L35

2つ目はuseUrlFromClipBoard、 下記のreadClipboardText中でmutateを実行。 windowが依存配列になっているが、useEffectが初期時も実行されるはずなので、結局初期時に同期してる気がする。

useEffect(() => {
    if (typeof window !== 'undefined') {
      window.addEventListener('focus', readClipboardText);
    }
    return () => {
      if (typeof window !== 'undefined') {
        window.removeEventListener('focus', readClipboardText);
      }
    };
  }, [window]);

3つ目は、useSocketIdで、こちらも初期時にmutateしている。

  useEffect(() => {
    socket.on('connect', () => {
      console.log('socket connected!!');
    });
    socket.on('disconnect', () => {
      console.log('socket disconnected!!');
    });
    socket.on('update-page', () => {
      console.log('Get Updated Data');
      pageListMutate();
    });
    socket.on('issue-token', ({ socketId }: { socketId: string }) => {
      mutateSocketId(socketId);
    });

    return () => {
      socket.close();
    };
  }, []);

結果、どれもコンポーネント初期読み込み時にmutateしていた。 多分これをしないと、全呼び出し元で初期データが同期されないはず。

まとめ

  • useSWRは第2引数にnullを設定することでローカル(API fetchではなく)での状態管理に利用できる
  • 上記を汎用的なhooksにしておくと便利
  • SPA遷移しても状態は保持される
  • 一番の注意点は、同keyのSWRを複数で使う場合、初期時は同期処理をしないと各コンポーネントにデータが同期されない(その後は更新があれば同期されるはず)