TopHotwire for Frontend Developers
beta

Commentary

Loading Animations

ローディングアニメーション

下記のビデオは2024年8月に記録したNewsPicks社のウェブサイトです。Next.jsのSSRとGraphQLを使って作成されているようです。しかしUX上の大きな問題があります。

下記ビデオをご覧になっていただくとわかりますが、ボタンをクリックしても全くフィードバックがなく、1秒後ぐらいにやっと画面が切り替わります。ユーザは自分がちゃんとクリックしたかどうかに自信が持てず、不安になります。またサイト全体がモッサリしている感覚があります。

これは決してNewsPicks社が悪いわけではなく、AJAX/fetchによる非同期通信を使ってウェブページを更新する全てのサイトに共通する課題です。強いていうと、Next.js Pages RouterのSSRはフレームワークでも公式ドキュメントでも解決策を提案していなかったのが原因と言えます。

同じことはTurbo FramesやTurbo Streamsを使っている場合も起こり得ます。ここでは問題の原因を解説し、解決策を紹介します。

  1. フィードバックの重要性
  2. Fetchではローディングアニメーションが出ない
  3. ブラウザネイティブなMPAの場合
  4. Turbo Driveの場合
  5. Next.js Pages Router SSRの場合
  6. Turbo Framesの場合
  7. ReactでuseEffectを使った場合
  8. React Server Componentsの場合
  9. HotwireとNext.jsの比較
  10. 最後に

フィードバックの重要性#

ユーザビリティの第一人者であるジェイコブ・ニールセンは「ユーザインタフェース設計の10の経験則」を発表しています。その一番最初のものが「システムステータスの可視化」です。

  • Communicate clearly to users what the system’s state is — no action with consequences to users should be taken without informing them.
  • Present feedback to the user as quickly as possible (ideally, immediately).

なるべく早くユーザにフィードバックを提供することの重要性が謳われています。

ボタンをクリックした時のCSS擬似セレクターの:activeを使用するなど、ユーザにフィードバックする方法はローディングアニメーションだけではありませんが、:activeはボタンは離すとすぐに消えてしまう問題があります。サーバからのレスポンスが1秒以上かかるのであれば、やはりローディングアニメーションのようなものが必要と言えるでしょう。

Fetchではローディングアニメーションが出ない#

ウェブブラウザは30年前からローディングアニメーションを表示していました。これはページのデータが読み込まれている間、「ちゃんと働いているよ」の合図となり、特にネットワークが非常に遅かった時代には必須なUI要素でした。

現在でも主要なブラウザはいずれもこのようなローディングアニメーションを用意しています。以前ほどは目立ちませんが、ブラウザが動いているのかどうかは明確にわかります。

しかしこのローディングアニメーションは、Ajax/fetchを使った場合には表示されません。MPA的な画面遷移であればアニメーションは表示しますが、Ajax/fetchによる遷移の時はこれをを表示しないのです。

上記のNewspicksのウェブサイトはNext.jsのSSRで動いています。そのためLinkタグを使っている場合、2ページ目以降はAjax/fetchを使って遷移します。だからローディングアニメーションが表示されません。 本来は開発者がアニメーションを自分で実装する必要があるのですが、それを怠ったために、ブラウザネイティブなMPAよりもむしろUXが低下してしまいました。

ブラウザネイティブなMPAの場合#

ブラウザネイティブなMPAの場合、30年前からローディングアニメーションが用意されています。

デモを使って解説します。まずトップ画面最上部で遅延(delay)を2000msに設定し、ブラウザネイティブ(MPA)画面遷移のデモ画面に移動してください。画面下部の「...へ遷移(Turbo Off)」のボタンをクリックすると、ブラウザネイティブの画面遷移をご確認いただけます。その時にブラウザネイティブのローディングアニメーションが表示されることをご確認ください。(Safariだったらロケーションバーの下部を青い線が横ぎります。Chromeの場合はタブのfaviconのところが回転アニメーションになります)。

2000msの遅延が入っているのでリンク先のページが表示されるまでに時間はかかりますが、ローディングアニメーションのおかげでブラウザが正しく動作していることが確認でき、安心感があります。

Turbo Driveの場合#

Turbo Driveの場合は、ネイティブのMPAと非常によく似たローディングアニメーションを、ライブラリがデフォルトで用意してくれています。

これを確認するには、まずトップ画面最上部で遅延(delay)を2000msに設定し、Turbo Drive画面遷移遷移のデモ画面に移動してください。画面下部の「...へ遷移(Turbo On)」のボタンをクリックすると、Turbo Driveの画面遷移をご確認いただけます。ローディングアニメーションはページコンテンツの最上部を横切る青い線になります。これはTurboが用意してくれているもので、スタイルのカスタマイズが可能です。

なおTurbo Driveの場合はキャッシュが動作するので、同じページを再訪問するときは瞬間的にページ遷移します。

  1. 一番最初の画面遷移の時は ボタンを押す => ローディングアニメーション => 画面遷移 の順番で動作します。画面遷移までは待たされます
  2. 以前に訪問したページを再訪問する時は、ボタンを押す => 瞬間的に画面遷移 => ローディングアニメーション の順番で動作します。画面遷移は瞬間的でまで待たされません。

2番目の時はTurbo Driveのキャッシュが動作しています。ボタンを押した瞬間に表示されるものは"preview"を呼ばれ、同時に裏でサーバから最新のページが取得されます。ローディングアニメーションが表示されているのは、裏で最新のページを取得しているためです。最新ページが受信されると"preview"と差し替えます。こうやって瞬間的にページを表示することと、最新ページを表示することを両立させています。

サーバのレスポンスが遅い場合には非常に効果的なUXです。

Next.js Pages Router SSRの場合#

いよいよ一番最初で紹介したNewsPicksの問題の話です。

これを確認するには、まずトップ画面最上部で遅延(delay)を2000msに設定し、Next.js Pages Router SSR画面遷移に移動してください。画面下部の「...へSSR(アニメーションを隠す)」ボタンをクリックすると、Next.js Pages Router SSRの画面遷移をご確認いただけます。

ボタンをクリックしてもフィードバックがなく、ウェブサイトが反応しているかどうかが全く確認できません。2000ms後にページが切り替わり、初めて正しく反応してくれたことがわかります。ユーザは操作していても自信が持てず、全体に反応が鈍く、モッサリした印象を与えます。

これなら古典的なMPAの方がずっとマシです。

ローディングアニメーションを実装してUX改善

同じNext.js Pages Router SSR画面遷移の画面で隣の「...へSSR」ボタン((アニメーションを隠すの表示がない方)を押すと、今度は画面右上にローディングアニメーションが表示されます。

古典的なMPA、もしくはTurbo Driveと同じようなUXになります。リンク先のページが表示されるまでは時間がかかりますが、フィードバックがあるのでユーザは安心できます。モッサリ感も改善されます。

Next.jsはローディングアニメーションを用意していませんので、これはcomponents/LoadingIndicator.tsx (GitHub)のLoadingIndicatorコンポーネントでカスタム実装しています。下記に示したようにNext.jsのrouteChangeStartrouteChangeCompleteイベントに反応して表示する仕組みになっています。

components/LoadingIndicator.tsx

...
router.events.on('routeChangeStart', handleRouteChangeStart)
router.events.on('routeChangeComplete', handleRouteChangeComplete)
...

また下記のようにイベントハンドラの中では500msの遅延をさせています。ローディングアニメーションはすぐに表示するのではなく、500msを置いてから表示しています。サーバからレスポンスが500ms以内に返ってきた場合にいちいちアニメーションを表示していると、却ってうるさく感じてしまいます。そのための対策です。Turbo Driveのアニメーションもこうなっています。

components/LoadingIndicator.tsx

let abort = false
const handleRouteChangeStart = async (url: any, {shallow}: any) => {
  await sleep(500)
  !abort && setIsLoading(true);
}

特にサーバからのレスポンスが遅い場合、ローディングアニメーションの追加はUXを大きく改善させます。Turbo Driveはデフォルトでアニメーションを用意してくれますが、Next.jsの場合は上記のように自作する必要があります。残念ながら、現実問題として、そこまでやっていないサイトの方が多いのではないかと思います。

NewsPicksはサーバのレスポンスが遅いサイトです。そのため、UXへの悪影響を軽減するために、ローディングアニメーションの導入はぜひ検討するべきでしょう。それが難しい場合は、Next.jsのLinkタグによるSPA的画面遷移を諦め、aタグを使用してブラウザネイティブなMPA画面遷移に戻した方がむしろUXは改善するでしょう。

Turbo Framesの場合#

Turbo Driveではデフォルトでローディングアニメーションが表示されますが、Turbo Framesの場合は表示されません。Turbo Frameが小さかったり、複数あったりするケースもあるので、画面全体のローディング状態を示すアニメーションをデフォルトで表示するのは適切ではないと判断したのかもしれません。その代わりにturbo-frameごとにカスタマイズする仕組みが用意されています。

Turbo Framesがロード中の時はturbo-frameタグにbusyの属性がつけられます。同様にaria-busyもつきます。したがってCSSを使うだけで、turbo-frame周辺に限定したローディングアニメーションがつけられます。

本サイトでは500msの遅延もつけているので少し複雑になっていますが、下記のCSSでTurbo Frameのローディングアニメーションを実装しています(GitHub)。デモはこちらでご確認いただけます。

/public/hotwire/styles/input.css

  .turbo-with-loader {
      position: relative;
  }

  .turbo-with-loader .turbo-hide-on-loading {
      position: relative;
      transition-delay: 500ms;
  }

  .turbo-with-loader .turbo-hide-on-loading::before {
      visibility: hidden !important;
      opacity: 0 !important;
      transition-delay: 500ms;
      transition-property: opacity;
  }

  .turbo-with-loader[busy] .turbo-hide-on-loading {
      visibility: hidden !important;
  }

  .turbo-with-loader[busy] .turbo-hide-on-loading::before {
      content: '';
      /* Visible will show at the beginning of the transition */
      visibility: visible !important;
      /* This will set opacity at the end of the transition */
      opacity: 1 !important;
      position: absolute !important;
      display: block;
      background-image: url('../../images/rocket.gif');
      width: 64px;
      height: 64px;
      top: 40px;
      left: 50%;
      transform: translateX(-50%);
  }

/public/hotwire/styles/input.css

<!-- ローディングアニメーションを表示するTurbo Frame -->
<turbo-frame id="tabs" class="turbo-with-loader" data-turbo-action="replace">
     ...
     <!-- ローディングアニメーション表示中に隠すコンテンツ -->
     <div class="my-10 px-4 sm:px-6 lg:px-8 turbo-hide-on-loading" >
        ...
     </div>
     ...
</turbo-frame>

サーバのレスポンスが遅いサイトで、ローディングアニメーションも用意せずにTurbo Framesを使用すると、上記のNewsPicksのようなUXになってしまいがちです。上記のようにCSSだけでローディングアニメーションが実装できますので、なるべくなら用意したほうが良いでしょう。ただしやり過ぎると画面がうるさくなってしまいますので、ケースバイケースで検討する必要があります。

うるさいと感じる場合、ボタンやリンクだけにロード中の印をつけるのも良いでしょう。例えばTurboでformの送信をするときは、data-turbo-submits-with属性を使えば、送信中にボタンの文言を変更できます

またTurbo Framesの場合はTurbo Driveの"preview"キャッシュ機能が働きません。スクロール位置などのステートを維持する必要がなければ、Turbo Framesではなく、Turbo Driveの方がより良い選択かもしれません。

ReactでuseEffectを使った場合#

Reactの古典的なSPAの場合は、useEffectを使ってデータをfetchします。この場合は条件付きレンダーを使用してローディングアニメーションを表示します。

Next.js useEffect画面遷移およびタブメニューUI useEffectにデモを用意していますので、UXをご確認ください。

この場合はリンクをクリックすると瞬間的にローディングアニメーションが画面いっぱいに広がります。リンク先の内容が表示されるまでは時間がかかりますが、ブラウザが反応していることは明確なので、ユーザは安心です。モッサリ感もなく、比較的良いUXです。少なくとも下手に実装されたSSRよりはずっと良いものです。

古典的なMPAやTurbo Driveと比べると、画面が先にクリアされることが大きな違いになります。旧画面の上にローダーを重ねるのは難しく、旧画面は即時に全て消されます。ただしサーバのレスポンスが極端に遅くなければこれが問題になることはなく、むしろ初期レスポンスが速く感じられるでしょう。

コードは一般に以下のようになります。

pages/users/index.tsx

export default function UsersIndex() {
  ...
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log("Fetch start for Users useEffect")
    fetch("/api/users").then(res => res.json())
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
  },[])

  ...

  return (
    <Layout>
      {loading
        ? <div className="flex justify-evenly w-full mt-24 h-96 mb-48">
          <Image src={rocketImage} alt="loader" className="w-16 h-16"/>
        </div>
        : <>
          ...
         </>
       }
    </Layout>
  )
}

Next.jsのPages RouterのSSRに比べて、こっちのローディングアニメーションの方が考えやすく、かなり実装しやすくなっています。

Server Componentsの場合#

Next.jsの新しいApp Router Server ComponentsおよびSuspenseも確認します。

まずトップ画面最上部で遅延(delay)を2000msに設定し、Next.js App Router Server Component画面遷移に移動してください。画面下部の「...へApp router」ボタンをクリックすると、Next.js App Router Server Componentsの画面遷移をご確認いただけます。

コードはGitHubに公開しています(users, products)。

今回はloading.tsxファイルを用意していますので、React Server ComponentsのSuspenseを使ったLoading UIがあります。ご覧のようにuseEffectを使った場合に非常に近いUXが実現されています。

今回デモは用意していませんが、loading.tsxのファイルを削除すると、UXはPages Router SSRでローディングアニメーションを設置しなかった場合と同じになります。つまり非常に反応が鈍く、モッサリしたものになってしまいます。特にサーバのレスポンスが遅い場合は、loading.tsxファイルによるSuspenseがUX上非常に重要であることがわかります。

タブメニューUIの方でも、Next.js App RouterのParallel Routesを使ったデモを用意しています。GitHubをご覧いただくとわかる通り、ここでもloading.tsxを使っていますので、SSRの動作ではなく、useEffectを使った場合に近いUXになります。

このようにReact Server ComponentsのSuspenseを使うと、Pages RouterのSSRで問題となるフィードバックの無さを解消し、useEffect的なUXのレベルに戻してくれます。loading.tsxファイルを作るだけですので、上に示したSSR用のカスタムアニメーションの実装もかなり簡単です。

HotwireとNext.jsの比較#

上記の議論を下図にまとめました。下記のことが言えるのではないかと思います。

  • Hotwire/Turboはデフォルトで全画面遷移時のローディングアニメーションを用意してくれています
  • 一方でNext.jsのPages Router SSRはローディングアニメーションの仕組みが用意されていません。そのため、古典的SPAで使われてきたuseEffectと条件付きレンダーの組み合わせに比べ、却ってUXが落ちてしまうことがあります。SSRでローディングアニメーションを実現するにはイベントハンドラを書く必要があり、難易度は高めです。ただしSuspenseの登場により、App Routerならば簡単に実現できます。デフォルトでは用意されていないので注意は必要ですが、大きな改善と言えます。
  • 上記は前画面推移の話ですが、画面の部分置換をする時は、HotwireもNext.jsもデフォルトではローディングアニメーションを用意していません。ただしHotwireはCSSだけでこれを実現する方法を用意していますし、Next.jsもApp RouterであればSuspenseで簡単に実装できます。Pages Router SSRについて言えば、SSRではそもそも画面部分置換の機能はなく、useEffectと条件付きレンダーを使用するしかありませんので、下手なSSR化をした時のようにUXが落ちることも起こりません。
  • Hotwireの場合はprefetchを使って、リンクをクリックする前からフライングをしてリンク先のページを読み込むこと(prefetch)ができます。Next.jsでは、動的ページの場合、これができません(App Routerなら各Linkタグに個別指定すればできます)。その分だけHotwireの反応は速くなり、そもそもローディングアニメーションが必要になるケースを減らしてくれます。
  • HotwireではTurbo Driveの"preview"というキャッシュ機能もあります。以前に訪問したページを再訪問するときに使用され、大幅な高速化が実現できます。ただしTurbo Framesでは使われません。そのため画面置換の粒度が粗い方が、却ってUXがよくなる可能性があります。
  • React、Next.jsで条件付きレンダーやSuspenseによるローディングアニメーションを作る際、部分置換する枠の中の旧コンテンツはすぐに消去されます。旧画面を残しつつ、その上にアニメーションを重ねるUIは困難です。一方でMPAやHotwireの場合は旧画面が残りますので、アニメーションを重ねることもできますし、CSSで非表示にすることもできます。どのようにローディング画面を表示するかについては、Hotwireの方が若干柔軟性が高くなっています。

最後に#

特にサーバのレスポンスが遅い場合は、ローディングアニメーションは必須と言えます。無いとUI/UXに大きな影響があります。使用する技術によって難易度はそれぞれ異なりますが、HotwireもNext.jsもこれを実装する方法は用意されています。特にHotwire Turbo Driveの場合はデフォルトでローディングアニメーションが用意されていますので、インストールするだけで使用できます。

ただしNext.jsのPages Router SSRではアニメーションの実装が難しい部分があり、それを省略してUXが悪くなっているケースがあります。App RouterのSuspenseでこの問題も解消されていますので、改善が期待されます。

一方で闇雲にアニメーションを用意すれば良いというものでもありません。ケースバイケースで判断しながら、適切な方法を選択するのが良いでしょう。