Commentary
ローディングアニメーション
下記のビデオは2024年8月に記録したNewsPicks社のウェブサイトです。Next.jsのSSRとGraphQLを使って作成されているようです。しかしUX上の大きな問題があります。
下記ビデオをご覧になっていただくとわかりますが、ボタンをクリックしても全くフィードバックがなく、1秒後ぐらいにやっと画面が切り替わります。ユーザは自分がちゃんとクリックしたかどうかに自信が持てず、不安になります。またサイト全体がモッサリしている感覚があります。
これは決してNewsPicks社だけが悪いわけではなく、AJAX/fetchによる非同期通信を使ってウェブページを更新するすべてのサイトに共通する課題です。強いていうと、Next.jsが解決策を提供していなかったのが原因と言えると思います。
同じことはTurbo FramesやTurbo Streamsを使っている場合も起こり得ます。ここでは問題の原因を解説し、解決策を紹介します。
ユーザビリティの第一人者であるジェイコブ・ニールセンは「ユーザインタフェース設計の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秒以上かかるのであれば、ローディングアニメーションやプログレスバーのような形でのフィードバックが必要と言えるでしょう。
ウェブブラウザは30年前からローディングアニメーションを表示していました。これはページのデータが読み込まれている間、「ちゃんと働いているよ」の合図となり、特にネットワークが非常に遅かった時代には必須なUI要素でした。
現在でも主要なブラウザはいずれもこのようなローディングアニメーションを用意しています。以前ほどは目立ちませんが、ブラウザが動いているのかどうかは明確にわかります。
しかしこのローディングアニメーションは、Ajax/fetchを使った場合には表示されません。MPA的な画面遷移であればアニメーションは表示しますが、Ajax/fetchによる遷移の時はこれをを表示しないのです。
上記のNewspicksのウェブサイトはNext.jsのSSRで動いています。そのためLink
タグを使っている場合、2ページ目以降はAjax/fetchを使って遷移します。だからローディングアニメーションが表示されません。本来は開発者がアニメーションを自分で実装する必要があるのですが、それを怠ってしまったのです。その結果、Next.js/Reactを使っているにも関わらず、古典的なMPAよりもむしろUXが低下してしまいました。
ブラウザネイティブなMPAの場合、30年前からローディングアニメーションが用意されています。
デモを使って解説します。まずトップ画面最上部で遅延(delay)を2000msに設定し、ブラウザネイティブ(MPA)画面遷移のデモ画面に移動してください。画面下部の「...へ遷移(Turbo Off)」のボタンをクリックすると、ブラウザネイティブの画面遷移をご確認いただけます。その時にブラウザネイティブのローディングアニメーションが表示されることをご確認ください。(Safariだったらロケーションバーの下部を青い線が横ぎります。Chromeの場合はタブのfaviconのところが回転アニメーションになります)。
2000msの遅延が入っているのでリンク先のページが表示されるまでに時間はかかりますが、ローディングアニメーションのおかげでブラウザが正しく動作していることが確認でき、安心感があります。
Turbo Driveの場合は、ネイティブのMPAと非常によく似たローディングアニメーションを、ライブラリがデフォルトで用意してくれています。
これを確認するには、まず[トップ画面最上部で遅延(delay)を2000msに設定]し、Turbo Drive画面遷移遷移のデモ画面に移動してください。画面下部の「...へ遷移(Turbo On)」のボタンをクリックすると、Turbo Driveの画面遷移をご確認いただけます。ローディングアニメーションはページコンテンツの最上部を横切る青い線になります。これはTurboが用意してくれているもので、スタイルのカスタマイズが可能です。
なおTurbo Driveの場合はキャッシュが動作するので、同じページを再訪問するときは瞬間的にページ遷移します。本題とはずれますが、ここで解説します。
2番目の時はTurbo Driveのキャッシュが動作しています。ボタンを押した直後に表示されるものは"preview"を呼ばれ、瞬間的に表示されます。同時に裏で最新のページのリクエストがサーバに送信されます。ローディングアニメーションが表示されるのはこのためです。最新ページが受信されると"preview"と差し替えます。こうやって瞬間的にページを表示することと、最新ページを表示することを両立させています。
サーバのレスポンスが遅い場合には、これは非常に効果的なUXです。
いよいよ一番最初で紹介したNewsPicksの問題の話です。
これを確認するには、まず[トップ画面最上部で遅延(delay)を2000msに設定]し、Next.js Pages Router SSR画面遷移に移動してください。画面下部の「...へSSR(アニメーションを隠す)」ボタンをクリックすると、Next.js Pages Router SSRの画面遷移をご確認いただけます。
ボタンをクリックしてもフィードバックがなく、ウェブサイトが反応しているかどうかが全く確認できません。2000ms後にページが切り替わり、初めて正しく反応してくれたことがわかります。ユーザは操作していても自信が持てず、全体に反応が鈍く、モッサリした印象を与えます。
これなら古典的なMPAの方がずっとマシです。
同じNext.js Pages Router SSR画面遷移の画面で隣の「...へSSR」ボタン((アニメーションを隠すの表示がない方)を押すと、今度は画面右上にローディングアニメーションが表示されます。
古典的なMPA、もしくはTurbo Driveと同じようなUXになります。リンク先のページが表示されるまでは時間がかかりますが、フィードバックがあるのでユーザは安心できます。モッサリ感も改善されます。
Next.jsはローディングアニメーションを用意していませんので、これはcomponents/LoadingIndicator.tsx
(GitHub)のLoadingIndicator
コンポーネントでカスタム実装しています。下記に示したようにNext.jsのrouteChangeStart
、routeChangeComplete
イベントに反応して表示する仕組みになっています。
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 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の古典的な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に比べて、こっちのローディングアニメーションの方が考えやすく、かなり実装しやすくなっています。
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用のカスタムアニメーションの実装もかなり簡単です。
上記の議論を下図にまとめました。下記のことが言えるのではないかと思います。
useEffect
と条件付きレンダーの組み合わせに比べ、却ってUXが落ちてしまうことがあります。SSRでローディングアニメーションを実現するにはイベントハンドラを書く必要があり、難易度は高めです。ただしSuspenseの登場により、App Routerならば簡単に実現できます。デフォルトでは用意されていないので注意は必要ですが、大きな改善と言えます。useEffect
と条件付きレンダーを使用するしかありませんので、下手なSSR化をした時のようにUXが落ちることも起こりません。Link
タグに個別指定すればできます)。その分だけHotwireの反応は速くなり、そもそもローディングアニメーションが必要になるケースを減らしてくれます。特にサーバのレスポンスが遅い場合は、ローディングアニメーションは必須と言えます。無いとUI/UXに大きな影響があります。使用する技術によって難易度はそれぞれ異なりますが、HotwireもNext.jsもこれを実装する方法は用意されています。特にHotwire Turbo Driveの場合はデフォルトでローディングアニメーションが用意されていますので、インストールするだけで使用できます。
ただしNext.jsのPages Router SSRではアニメーションの実装が難しい部分があり、それを省略してUXが悪くなっているケースがあります。App RouterのSuspenseでこの問題も解消されていますので、改善が期待されます。
一方で闇雲にアニメーションを用意すれば良いというものでもありません。ケースバイケースで判断しながら、適切な方法を選択するのが良いでしょう。