# UXの最適化
UXの最適化とは、ユーザー体験の向上によるパフォーマンス改善を目指すものです。実際の処理時間が同じでも、見せ方によってユーザーの体感速度は異なります。フロントエンドでの実装次第で、アプリケーションの使いやすさは大きく変わります。この章では、UXの最適化により、どのようにユーザー体験が変わるのか見ていきたいと思います。
# スケルトンローダー
スケルトンローダーとは、画面の読み込み中にコンテンツ部分に仮のUI(スケルトンスクリーン)を表示するローディング方法になります。例えば、Youtubeではページの読み込み中に動画のスケルトンスクリーンを表示しています。
出典: Youtube (opens new window)
スケルトンローダーの目的は、仮のコンテンツを表示することで、視覚的にユーザーが待ち時間を感じづらくさせることです。また、レイアウトをスケルトンスクリーンで保持してくことでスムーズにコンテンツの表示ができるようになります。あらかじめレイアウトを作っておくことでCLSの最適化にも有効的でしょう。
例えば、読み込み中に真っ白な画面が表示されているとしましょう。このままでは、読み込み中なのか、エラーが発生したのか、アクセスされているのか、今の状態を把握することが難しくなります。 アプリケーションでは、今の状態を正確にユーザーに伝えることが大切です。今の状態を知ることで、今何をしているのか、次に何をすべきか、を理解することができます。読み込み中の場合は、ローティングを明示的に出すことページを読み込み中であることをユーザーに知らせることができます。スケルトンローダーは、スケルトンスクリーンで仮のコンテンツを表示します。そのため、ユーザーにとってどの画面を開いているのか、より分かりやすくなり、ページ読み込みのユーザー体験を改善させることができます。
同じようなローティング方法として、プログレスバーやスピナーなどがあります。これらもローディングを表現するために使われますが、スケルトンローダーとは用途が多少異なります。
例えば、プログレスバーは画面の読み込みというよりは、ファイルのダウンロードやアップロードなど、何かデータを処理をしているときに適しています。
スピナーもページの読み込みで使うことができますが、スケルトンローダーに比べると情報量が少なく、レイアウトをあらかじめ表示できないなどのデメリットがあります。
そのため、スピナーは局所的に使うのが有効的でしょう。例えば、フォームの送信ボタンやちょっとしたローディングを表現するのに適しています。
ほとんどのアプリケーションは、何らかのデータを取得したり、処理する時間が発生します。この待ち時間が多少長くなったとしても、操作性には問題ないかもしれません。しかし、この小さな待ち時間の積み重ねがアプリケーション全体のユーザー体験を損なう原因になることがあります。実際の処理時間が変わらなくても、視覚的に工夫することでユーザーが速くなったと感じれば、それはUX上の速度向上と言えるでしょう。UXの知識は、UXデザイナーだけでなく、フロントエンドエンジニアにも必要なスキルです。そのため、実際にUIを実装するエンジニアもパフォーマンスや使い勝手を意識する必要があります。
スケルトンローダーやスピナー、プログレスバーなどを活用してローディング時間を適切にデザインをすることで、快適なユーザー体験を実現することができます。さらに、マイクロインタラクション (opens new window)のように、ちょっとした工夫をするだけでも、ユーザーにとって分かりやすいUIを提供できるでしょう。
# 仮想リスト
仮想リストとは、ユーザーが見えている範囲だけのリストを動的にレンダリングする表示方法です。仮想リストは、全てのリストをレンダリングせず、必要なリストだけをレンダリングすることで、パフォーマンスの最適化を目指します。例えば、1000件のリストを表示する場合を考えてみましょう。通常、一度に全てのリストをレンダリングするとブラウザーに多くの負担がかかります。
レンダリングの処理でメモリ使用量が圧迫するため、スペックの低いデバイスなどではクラッシュする可能性もあるでしょう。仮想リストの場合、一度に1000件をレンダリングしない代わりに、ユーザーが見えている部分だけをレンダリングします。例えば、20件のリストが画面に表示されているときは、20件分のリストだけを動的にレンダリングして置き換えます。
例えば、react-window (opens new window)を使った例を見ると、スクロールするたびにコンテンツが置き換えられるていることが分かります。
仮想リストでは、スクロールで上下するたびに20件分のレンダリングを実行します。 そのため、ブラウザーへの負荷は毎回20件分のレンダリングコストしか発生しません。1000件を一度にレンダリングするときと比べると、メモリ使用量を大幅に削減することができます。現実的に、一度に1000件もレンダリングすることはないと思いますが、ページネーションや無限スクロールを使わないでリストのレンダリングパフォーマンスを最適化したいときは有効な選択肢になります。
# 仮想リストと無限スクロール
仮想リストは、無限スクロールと併せて使うことができます。例えば、Twitterでは仮想リストと無限スクロールを実装して、詳細画面から戻ってきたときに元にいた位置を復元できるようになっています。
これは、次のような仕組みで実装することができます。
- 仮想リストでリストをレンダリング
- 詳細ページのリンクをクリックする
- 詳細ページへ遷移する前にそのときのリストと座標軸を取得する
- 詳細ページへ遷移する
- 詳細ページから戻る
- 仮想リストで元のリストからレンダリングする
- 遷移前に保存した座標軸からwindow.scrollToでスクロール位置を復元する
このように仮想リストと無限スクロールを組み合わせることで、レンダリングパフォーマンスの最適化ができると同時に、ユーザーによって使いやすいスクロール体験を提供することができます。Reactでは、react-window-infinite-loader (opens new window)などの無限スクロール用のライブラリを使うか、仮想リストライブラリと併せて上記の方法で実装することができます。
# Optimistic UI
Optimistic UI(楽観的UI)とは、データの更新時にサーバーからのレスポンスを待たずにUIの更新を先に反映させるという更新方法です。通常、データを更新したときはサーバーからのレスポンスを待ってからUIを更新します。これは、データベースとフロントエンドとの整合性を保つため、データベースの更新が完了してからUIに反映させるというアプローチです。
サーバーからのレスポンスを待つアプローチでは、データの整合性を保つという意味で安全性があります。しかし、サーバーでの処理が遅れてしまうと、その分UIの反映も遅れてしまうという問題点があります。特に更新性が高いアプリケーションで、ローディングが頻繁に続くとユーザー体験が損なわれてしまいます。例えば、チャットアプリケーションで、メッセージを送信するたびにローディング中が頻発するようではユーザーにとって使いやすいとは言えないでしょう。
Optimistic UIは、このような問題点を解決するため、UIの反映を先に行うというアプローチをとります。UIの反映を先に行い、バックグラウンドでサーバーへの更新をリクエストします。サーバーからのレスポンスが返ってきたときには、成功していた場合はUIの反映は行いません。(データの整合性を保つため、内部データの更新をする場合はあります。)エラーだったときだけ、UIの状態を元に戻して、エラーの処理を実行します。
サーバーからのレスポンスを待つ手法では、UIの反映までのステップは次のようになります。
- フロントエンドからデータを更新
- サーバーへリクエストする
- サーバーからレスポンスを受け取る
- UIへの更新
UIへの更新が一番最後になっていることが特徴です。サーバーでの処理が5秒かかった場合は、UIへの反映まで5秒かかることになります。一方、Optimistic UIでは次のようなステップになります。
データの更新が成功するパターン
- フロントエンドからデータを更新
- UIへの更新
- サーバーへリクエストする
- サーバーからレスポンスを受け取る
UIへの更新が2番目で実行されているので、即座に画面に反映することができます。サーバーからレスポンスを待つ必要がないので、ほとんどタイムラグがない状態でUIへの更新をすることができます。そのため、いくら時間のかかる処理でも、ユーザーはすぐに画面の更新が行われたと認識できるでしょう。
注意する点としては、エラーハンドリングは適切に行う必要があります。エラーになったときは元のUIの状態に戻して、エラーの通知をする必要があります。Optimistic UIの意味は楽観的UIですが、その名前の通り、基本的にほとんどのデータの更新は成功するものとして実行します。そのため、先にUIに反映させておこうという考え方になります。もし、失敗したら元に戻して適切にハンドリングし、データの整合性を保つ必要があります。
# 実装方法
Optimistic UIをサポートしているライブラリやフレームワークはいくつかあります。
ここでは、SWRで実装方法を見てみましょう。以下はTO DOアプリケーションになります。TO DOを追加したときに即座に追加するようになっています。サーバーへの更新は擬似的に行い、ランダムで成功か失敗を返しています。
注目する点としては、エラーがあったときにTO DOタスクを削除していることです。エラーだったのにそのまま残っていると、データの不整合を起こしてしまうのでしっかりと元のUIに戻すことが必要です。SWRでは、エラーのときは自動的に元のデータに戻してくれるので、明示的にデータのロールバックを実装することはありませんが、エラーの通知だけ実装する必要があります。
実装コードは以下になります。
import React, { useState } from "react";
import toast, { Toaster } from "react-hot-toast";
import useSWR from "swr";
import { getTodos, addTodo } from "./api";
export default function App() {
const [text, setText] = useState("");
const { data, mutate } = useSWR("/api/todos", getTodos);
return (
<div>
<Toaster toastOptions={{ position: "bottom-center" }} />
<h1>Todos </h1>
<form onSubmit={(ev) => ev.preventDefault()}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
autoFocus
/>
<button
type="submit"
onClick={async () => {
setText("");
const newTodo = {
id: Date.now(),
text
};
try {
await mutate(addTodo(newTodo), {
// データを即座にUIをへ反映させる
optimisticData: [...data, newTodo],
rollbackOnError: true,
populateCache: true,
revalidate: false
});
toast.success("Successfully added the new item.");
} catch (e) {
// 失敗した場合はエラーの通知をする
toast.error("Failed to add the new item.");
}
}}
>
Add
</button>
</form>
<ul>
{data
? data.map((todo) => {
return <li key={todo.id}>{todo.text}</li>;
})
: null}
</ul>
</div>
);
}
optimisticData (opens new window)オプションに新しいデータを渡すと、即座にUIへ反映させることができます。
await mutate(addTodo(newTodo), {
// データを即座にUIをへ反映させる
optimisticData: [...data, newTodo],
rollbackOnError: true,
populateCache: true,
revalidate: false
});
このように、SWRではオプションに追加するだけでOptimistic UIを実装することができます。自前で実装する場合は、Stateの更新、UIへの反映、サーバへのリクエスト、ロールバック、エラーハンドリングを実装する必要があります。通常の処理と比べると手間は増えますが、そこまで複雑なフローではないはずです。ただ、ロールバックとエラーハンドリングは適切に行う必要があります。実装コストが高いと感じる場合は、上記のようなライブラリを積極的に検討してみましょう。
# 適したアプリケーション
Optimistic UIは更新性が高いアプリケーションに適しているでしょう。例えば、チャットアプリケーションやWebSocketsを伴うリアルタイムアプリケーション、タスク管理ツール、カンバンボードなどが当てはまります。または、部分的に使うという方法もあります。例えば、いいねボタンやお気に入りボタンなど、すぐにUIに反映させたいものに対してOptimistic UIは有効です。
一方、それほど更新性やリアルタイム性がない場合、従来通りのサーバーからレスポンスを待つアプローチで問題ないでしょう。Optimistic UIはエラーハンドリングをより適切に行う必要があるため、その分実装コストは伴います。そのため、必要でなければ無理に導入することはないでしょう。