React&SWRでレンダリング時のチラつきを撲滅する3つのテクニック

UI flickering はイケてない

WESEEK エンジニアの武井です。

みなさん、フロントエンドプログラミングやってますか?
今回のネタは「UI flickering」。React の初回レンダリングやステートを更新した際、こんな感じの挙動してないでしょうか。

react-ui-flickering-when-state-updated

出典: https://stackoverflow.com/questions/55032136/react-ui-flickering-when-state-updated

この一瞬今描画しているアイテム消えてパッと次のアイテムが出てくるまでの一瞬の間が見える現象、こちらが「UI flickering」です。

和製英語だと「UIフリッカー」とか言われる事もありますがあまり一般的ではなく、「カクつき」「チラつき」みたいな表現の方が馴染みがあるかもしれません。

UI flickering は、普通に React で Backend/DB からデータ取ってきて更新するコンポーネントを何も考えずに書くと大体起こってしまいます。UI/UX が壊滅的に悪くなるものではないのでプロダクト・実装箇所によっては許容されるかもしれませんが、イケてるかイケてないかで言えばやっぱりあんまりイケてない部類に入ります。

今回はそれを撲滅するための3つの Tips を紹介します。

目次

対策1. ローディング中はスケルトンを表示する

一番単純な戦略は、スケルトンコンポーネントを用意し、ローディング中はそちらを表示することです。

const MyComponent = (): JSX.Element => {

  const [isLoading, setLoading] = useState(false);

  ...

  return isLoading
    ? <MyListSkelton length={data.length} />
    : <MyList />;
}

MyList コンポーネントと同じような MyListSkelton を用意しておき、代わりにそれを表示するというものです。
このようなシーンではスピナーを表示することも多いと思いますが、実際に表示したいアイテム群と同じ幅・高さを持つスケルトンに置き換えることで UI flickering を抑制できます。

MUI Skelton Example
出典: https://mui.com/material-ui/react-skeleton

スケルトンについては Material UI などが参考になります。react-loading-skelton も汎用的で便利。React 以外でも使えるのだと Placehold-it も有名ですね。素の Bootstrap 5 にもこういうのあったらいいんだけどなあ。

SWR を使おう

対策の2つめの説明に入る前に、SWR を紹介します。

SWR とは "stale-while-revalidate" の頭文字を取ったもので、HTTP 通信に於けるキャッシュ戦略の概念・仕様です。そして Vercel が作っている同名のライブラリ(実装)が存在し、React と一緒に使うことができます。

似たライブラリとしては React Query があります。

SWR のうれしみ

いくつかありますが主要なものを挙げると、

  • データ取得の後、結果のキャッシュ化が簡単になる
  • データ更新、再取得が必要になったときの手順が簡単になる

そして、

  • 「対策1. ローディング中はスケルトンを表示する」の isLoading の管理が簡単になる

こちらがこのエントリーで SWR を推す理由です。

SWR 利用 Example

import { useSWRxMyListData } from '../stores/mylist';

const MyComponent = (): JSX.Element => {

  const { data, error } = useSWRxMyListData();

  ...

  const isLoading = error == null && data === undefined;

  return isLoading
    ? <MyListSkelton length={data.length} />
    : <MyList />;
}
const fetcher = url => fetch(url).then(r => r.json())

export const useSWRxPage = (): SWRResponse<MyListData, Error> => {
  return useSWR<MyListData>('/_api/mylist', fetcher);
};

SWR 結果から isLoading が作られるため、通信開始時に true にして、finally で false に戻してみたいな管理をする必要がありません。

蛇足 1

因みに WESEEK では JavaScript/TypeScript のコード中で null/undefined チェックを行う場合は

if (value != null) {...}

のように、厳密等価演算子ではなく等価演算子と null 値との比較に統一していますが、SWR でデータ取得前の状態を判定する場合は === undefined を利用しています。理由は取得データが null 値の場合があるからですね。プロダクトの中では珍しいコードになっています。

/Tips/JavaScript#判定式

対策2. 初期ステートを localStorage から取得する

続いての対策は、ある条件下・シチュエーションのもとで効果を発揮します。

例えば VSCode のようなアプリを作っているとして、サイドバーの開閉状態をサーバー側(DB側)で保持するようなシナリオを考えてみましょう。

VSCode Sidebar Closed VSCode Sidebar Opened

  1. ユーザーがアプリを使う時に、サイドバーの開閉状態を変えた
    • 開閉状態を変更すると、その値が DB に保存される
  2. 次回アクセス時はサイドバー開閉状態が復元される

ソースコードは次のようになるでしょう。

import { useSWRxMySettings } from '../stores/mysettings';

const App = (): JSX.Element => {

  const { data: mySettings } = useSWRxMySettings(userId);

  ...

  return (
    <>
      <Sidebar isOpen={mySettings.isSidebarOpened} />
      <Contents />
    </>;
  )
}
const fetcher = url => fetch(url, params).then(r => r.json())

export const useSWRxMySettings = (userId: string): SWRResponse<UISettings, Error> => {
  return useSWR<UISettings>(
    ['/_api/mysettings', { userId }],
    fetcher
  );
};

さて、ここで初回のレンダリングをどうするかが悩ましいところです。

コンポーネントの状態を正確に再現するための設定値はバックエンドが持っているのでまず API にアクセスすることは必須ですが、その通信結果を待たないとユーザーが期待する UI、つまりサイドバーを開いて描画するか閉じて描画するかを決定できません。

もちろん何も考えずに「デフォルトステートを閉じた状態」とすると、保存された設定値が「開いた状態」だった際に UI flickering が起きるでしょう。また、「対策1. ローディング中はスケルトンを表示する」はそもそも幅・高さをとっていいケースなのかどうかが確定していない今回は使えませんね。

一つの選択肢として、「バックエンドからの設定値が届くまで、画面全体をロード中状態としてよい」という仕様でよいと割り切るのであれば、初回レンダリングが遅くなることと引き換えに UI flickering を避けられます。その遅延を UX 的に許容できない場合は別の対策が必要です。

localStorage への保存の検討

このケースへの対策として、DB に保存した設定値を localStorage にも保存・同期する戦略を検討してみます。

  1. ユーザーがアプリを使う時に、UI設定を変えた
    • 上記結果が DB に保存される
    • 同時にユーザーのブラウザの localStorage に同じ設定値が保存されるようにする
  2. 次回アクセス時、localStorage に入っている設定値を取り出し、SWR の初期キャッシュに入れる
    • 大体のケースで REST の通信のレスポンスよりも localStorage からの値抽出の方が早いので、こちらを利用して初回レンダリングが行われる
  3. DB から取得した結果を適用し、再度レンダリングされる
    • localStorage に入っている設定値と一致していれば、UI flickering が起こらない

ではこれをどう実現すればいいでしょうか。

汎用 SWR middleware (保存版)

はい、こちらをコピペでモジュール化してご利用ください。
解説は省きますが、SWR hook に指定可能な fallbackData に localStorage 由来の設定値を入れて初期化しています。

import { Middleware } from 'swr';

const generateKeyInStorage = (key: string): string => {
  return `swr-cache-${key}`;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type IStorageSerializer<Data = any> = {
  serialize: (value: Data) => string,
  deserialize: (value: string | null) => Data,
}

export const createSyncToStorageMiddlware = (
    storage: Storage,
    storageSerializer: IStorageSerializer = {
      serialize: JSON.stringify,
      deserialize: JSON.parse,
    },
): Middleware => {
  return (useSWRNext) => {
    return (key, fetcher, config) => {
      if (key == null) {
        return useSWRNext(key, fetcher, config);
      }

      const keyInStorage = generateKeyInStorage(key.toString());
      let initData = config.fallbackData;

      // retrieve initial data from storage
      const itemInStorage = storage.getItem(keyInStorage);
      if (itemInStorage != null) {
        initData = storageSerializer.deserialize(itemInStorage);
      }

      const swrNext = useSWRNext(key, fetcher, {
        fallbackData: initData,
        ...config,
      });

      return {
        ...swrNext,
        // override mutate
        mutate: (data, shouldRevalidate) => {
          return swrNext.mutate(data, shouldRevalidate)
            .then((value) => {
              storage.setItem(keyInStorage, storageSerializer.serialize(value));
              return value;
            });
        },
      };
    };
  };
};

export const localStorageMiddleware = createSyncToStorageMiddlware(localStorage);

export const sessionStorageMiddleware = createSyncToStorageMiddlware(sessionStorage);

使い方は、カスタムフック側を少し変えるだけで済みます。use: [middlewareInstance] という形式でオプションを足します。

const fetcher = url => fetch(url, params).then(r => r.json())

export const useSWRxMySettings = (userId: string): SWRResponse<UISettings, Error> => {
  return useSWR<UISettings>(
    ['/_api/mysettings', { userId }],
    fetcher,
    { use: [localStorageMiddleware] }, // store to localStorage for initialization fastly
  );
};

これで、「対策1. ローディング中はスケルトンを表示する」を適用できないが可能な限り初期値セットを急ぎたい場合に対応できました。

蛇足 2

GROWI では実際に以下の場所で利用しています。

蛇足 3

もちろんこの対策は銀の弾丸ではありません。

localStorage に DB 値のコピーを保存すると言うことは、PCを2台(またはブラウザ2種類)を使い分けているケースで不整合が発生します。頻繁に利用環境を変えるユーザーにとっては、かえって UI flickering が起こりやすくなることもあるかもしれません。トレードオフを考慮し採否を検討してください。

対策3. Revalidate 中に前のステートを初期化しない (>= SWR@2.0.0)

この対策は、SWR ライブラリのアップデートの先取りになります。

2022.06.13 時点で SWR ライブラリの最新バージョンは 1.3.0 ですが、開発チームは次のメジャーバージョンである 2.0.0 を開発中です。GitHub でβ版リリースを見ることができます 2.0.0-beta.1 のハイライトを見てみましょう。

公式の isLoading ステート

「SWR を使おう」セクションで isLoading ステートを作成するコードを紹介しました。こちらです。

const isLoading = error == null && data === undefined;

2.0.0 では error, data から作らなくても、useSWR を呼び出した結果からこの boolean を得られるようです。

const { data, isLoading, isValidating } = useSWR(STOCK_API, fetcher, {
  refreshInterval: 3000
});

// If it's still loading the initial data, there is nothing to display.
// We return a skeleton here.
if (isLoading) return <div className="skeleton" />;

これだけだと単に使い勝手が良くなった程度ですが、更に UI flickering に効果があるオプションも追加されています。

keepPreviousData オプション

出典: https://github.com/vercel/swr/releases/tag/2.0.0-beta.1

このオプションを利用することで、データの再取得時、isLoading が true の間も以前の data を返し続けてくれます。つまり、データ取得条件が変わってデータの再取得を行っている間に起こる UI flickering をオプション一つで抑制できるというわけです。

まとめ

  • 幅・高さが決まっているコンポーネントは、データロード中にスケルトンを表示することで UI flickering を抑制できる
  • SWR という stale-while-revalidate 戦略がある
    • React 向けには同名のライブラリがあって便利
  • SWR は、HTTP リクエスト/レスポンス前後のキャッシュコントロールを肩代わりし、賢く管理してくれる
    • プロダクトで独自実装が必要だったキャッシュコントロールのためのコードの多くが不要になる
  • SWR 2.0.0 にはもっと便利で UI flickring に効果があるオプションも追加される!楽しみ!
  • DB にしかない値を localStorage にも同期させることで、UI flickering を抑制できるケースもある
  • UI flickering / UIフリッカー / 画面のカクつきチラつきを撲滅せよ

以上です。読んでいただいてありがとうございました。

よかったら Twitter の方もフォローしてください。技術ネタや組織作りについてたまに呟きます → https://twitter.com/yuki_takei
一緒に SWR 広めましょう🙂