📂

SWR を 状態管理 として活用しているよという話

2021/06/13に公開

対象の読者

  • react の状態管理ライブラリ選定をしている方
  • SWR + Context API を使って状態管理を実現している方

結論

  • 全て SWR で完結すると考えることが少なくなるので楽

SWR とは

https://swr.vercel.app/ja
日本語でも書かれているので分かりやすいです。

簡単にいうと SWR は データ取得のための React Hooks ライブラリ(公式サイトからそのまま引用)です。

今回のタイトルにある 状態管理は、アプリケーションレベルで管理される state と定義します。
すなわち特定のコンポーネントに限らず Global state として複数のコンポーネントから参照させたい値を保持させることも可能です。

SWR はデータ取得のためのライブラリでは...?

あまり知られていないですが、SWR を状態管理としても使うことが可能です。[1]

ドキュメントに記載されている箇所は確認できませんでしたが、コードを読むとそのことが確認できます。
コードで提示されている箇所は以下の部分になります。[2]
https://github.com/vercel/swr/blob/11533ee8d5df6136726c4fc7a05bdbf1ac82fb35/src/resolve-args.ts#L34-L35

useSWR('username', null, { initialData: initialUsername })
こちらのように 第2引数(fetcher) に null を指定することで api のリクエストを生成させず状態を保持できます。

以上の点を踏まえ、実際に稼働しているアプリケーションの活用例を提示して SWR の魅力をお伝えしたいと思います。

Global state としての SWR の活用事例

https://github.com/itizawa/webev-front

個人開発しているブックマーク管理アプリケーション(以下 Webev)での事例を紹介いたします。
OSS なのでぜひ参考にしてみてください。

実際の動作は以下のリンクからログインしてページを保存すると試すことができます。

https://www.webev.cloud/ja

useSWR を実装するのに毎回 null を指定するのは面倒なので 関数を用意しています。
通常のSWRと区別するために useStaticSWR という名前にしています。[3]

stores/use-static-swr.tsx
import useSWR, { Key, SWRResponse, mutate, cache } from 'swr';
import { Fetcher } from 'swr/dist/types';

export const useStaticSWR = <Data, Error>(key: Key, updateData?: Data | Fetcher<Data>, initialData?: Data | Fetcher<Data>): SWRResponse<Data, Error> => {
  if (updateData == null) {
    if (!cache.has(key) && initialData != null) {
      mutate(key, initialData, false);
    }
  } else {
    mutate(key, updateData);
  }

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

前述の通り useSWR の第2引数に null を指定することで、api を実行し値を取得しに行かない SWR を生成しています。

生成した useStaticSWR を以下のように stores/contexts.tsx で wrap した関数を export して利用します。

stores/contexts.tsx より一部抜粋
export const useOgpCardLayout = (initialData?: OgpLayoutType): SWRResponse<OgpLayoutType | null, Error> => {
  return useStaticSWR<OgpLayoutType | null, Error>('ogpCardLayout', initialData);
};

イメージがしやすいように稼働しているアプリ画像と共に...

保存したページの一覧を表示しています。
ページ表示のレイアウトがカードとリストで変更ができるようになっています。

  • カードバージョン

  • リストバージョン

レイアウトの値を変更するのが右上の UserMenu となる PersonalDropDown コンポーネントです。

PersonalDropdownより一部抜粋
export const PersonalDropdown: VFC<Props> = (props: Props) => {
  const { data: ogpCardLayout = OgpLayoutType.CARD, mutate: mutateOgpCardLayout } = useOgpCardLayout();

  const handleClickOgpCardLayout = (type: OgpLayoutType) => {
    mutateOgpCardLayout(type);
  };
  
  // 中略
  
  return (
    // 中略
    <div className="px-3 my-3 d-flex justify-content-between">
      <button
        className={`btn btn-outline-indigo ${ogpCardLayout === OgpLayoutType.LIST ? 'active' : ''}`}
        onClick={() => handleClickOgpCardLayout(OgpLayoutType.LIST)}
      >
        <Icon height={20} width={20} icon={BootstrapIcon.LIST} color={BootstrapColor.WHITE} />
      </button>
      <button
        className={`btn btn-outline-indigo ${ogpCardLayout === OgpLayoutType.CARD ? 'active' : ''}`}
        onClick={() => handleClickOgpCardLayout(OgpLayoutType.CARD)}
      >
        <Icon height={20} width={20} icon={BootstrapIcon.GRID} color={BootstrapColor.WHITE} />
      </button>
    </div>
  );
};

紫色のボタンの部分を抜粋しています。

icon を押すと handleClickOgpCardLayout が実行され、引数の type を用いて mutateOgpCardLayout(type); を実行します。

こちらはどこから来ているかというと、上部の
const { data: ogpCardLayout = OgpLayoutType.CARD, mutate: mutateOgpCardLayout } = useOgpCardLayout();
です。

useOgpCardLayout() から返ってくる mutate を実行して Global state である layoutType を更新しているのです。

更新した値を使用しているのが PageList となります。

PageList.tsxより一部抜粋
export const PageList: VFC<Props> = (props: Props) => {
  const { data: ogpCardLayout } = useOgpCardLayout();

  return (
    <div className="row">
      {pages.map((page) => {
        if (ogpCardLayout === OgpLayoutType.LIST) {
          return (
            <div className="col-12" key={page._id}>
              <OgpListItem page={page} />
            </div>
          );
        }
        return (
          <div className="col-xl-4 col-md-6 mb-3" key={page._id}>
            <OgpCard page={page} />
          </div>
        );
      })}
      // 中略
    </div>
  );
};

useOgpCardLayout() から取得できる data には先ほどの PersonalDropdown で更新した layoutType が入ります。 PageList では、そのタイプによって List を表示するか Card を表示するか切り替えています。

ここまでを簡単に図にするとこのような感じです(一部簡略化)

気づいた方もいらっしゃるかと思いますが、通常の SWR と同じように扱えるようにしています。

data にはその都度最新のデータが入ってくるし、mutate を行えば値の更新ができます。必要であれば引数に更新後の値をセットして能動的に更新します。

例えばユーザーが保存したページをディレクトリに保存するのはこんな感じです。

AddDirectoryModal.tsx より一部抜粋
export const AddDirectoryModal: VFC = () => {
  const { data: pageForAddDirectory, mutate: mutatePageForAddDirectory } = usePageForAddDirectory();
  const { data: paginationResult, mutate: mutateDirectoryList } = useDirectoryListSWR();
  const { mutate: mutatePageList } = usePageListSWR();

  const addPageTODirectory = async (directoryId: string) => {
    try {
      await restClient.apiPut(`/pages/${pageForAddDirectory?._id}/directories`, { directoryId }); // ページをディレクトリに保存するための api
      toastSuccess(t.toastr_success_add_directory);
      mutatePageList();
      mutatePageForAddDirectory(null);
    } catch (error) {
      console.log(error);
      toastError(error);
    }
  };

  return (
    <Modal isOpen={pageForAddDirectory != null} toggle={() => mutatePageForAddDirectory(null)} size="lg">
      // 省略
    </Modal>
  );
};

Modal を閉じるために必要な pageForAddDirectory を null にする関数を mutatePageForAddDirectory で実行するし、ページを追加したことによる管理画面全体のページリストの再取得も mutatePageList で実行します。

すなわち、コンポーネントを開発するときにその SWR が情報をどう取得するのかを考える必要がなく、開発者はその SWR がどんなデータを持つのか、どんなデータを持たせたいかを考えるだけで良くなります。

Webev では 複数のコンポーネント間をまたがって使用される state の場合はその都度 SWR に切り出しています。
そうすることで以下のようなメリットがあります

SWR を状態管理として利用することによるメリット

  1. 命名を考える必要がない

1つ前のセクションで記述したように、SWR を使うコンポーネントでは常に同じように扱うことができます。
Webev では data は SWR の key をそのまま使うし、 mutate は mutate + key, とするようにしました。
(例 const { data: isSortCreatedAt, mutate: mutateIsSortCreatedAt } = useIsSortCreatedAt();)
その結果機械的に命名することで SWR 利用時の思考コストや、どんな変数だったかを確認する時間的コストが削減できたと思います。

  1. 親子のコンポーネント間が疎結合になった

親子各自が SWR である useHoge 関数を実行し値の取得更新を行うようになります。

子コンポーネントが削除ボタンを押したときに親が持つ api を子供に渡して実行...
と考える必要がなくなり、親子間の責務の切り分けを思考するコストが減りました。

疎結合になった結果 props の量が減ったことによるメンテナンスコストの減少と、このコンポーネントをインポートして表示するだけで自立して動作してくれるという気軽さが得られました。

まとめ

SWR を使うことでより気軽に状態管理を実現させることができました。
他にも SWR にはより便利な使い方があります。
状態管理やデータ取得をかなりシンプルにしてくれるので、ぜひ検討してみてください。

脚注
  1. SWRはローカルの状態管理としても使える も参考にしてみてください。 ↩︎

  2. コメントで提示されている記事のコードをそのまま使っても正常に動きません。
    上記コードの コメントで提示されているブログ ですと以下のように提示されています。
    const { data: username, mutate: setUsername } = useSWR('username', {initialData: ''})
    こちらのコードだと第2引数にオプションでも動くような書かれ方がしていますが、
    現 SWR のバージョンでは正常動作しないので注意が必要です。
    と言いますのも v0.3.3改修 が入りました。
    デフォルトの fetcher に url => fetch(url).then(res => res.json()) が指定される変更です。
    その結果、SWR の現バージョンで上記のコードを書くと key をもとにしたリクエストが発生してしまいます。 ↩︎

  3. 状態管理としての SWR のアイデアは本業でも用いられてます ↩︎

Discussion