React

Reactのスケルトン表示|300ms遅延とSuspense・useTransitionの使い分け

Reactで実カードと寸法を揃えたスケルトンを作ります。短い待ち時間では表示を遅らせ、Suspenseのfallbackと通常のloading状態を使い分け、useTransitionで表示済みの内容を保つ実装を解説します。

この記事の目次
  1. 初回の空き領域と、更新中の旧表示を分けて考える
  2. 実カードとスケルトンで、同じレイアウトを使う
  3. 300ms経っても読み込み中なら、スケルトンを見せる
  4. Suspenseは、Effect内のfetchを自動では待たない
  5. useTransitionで、読み込み済みのカードを残す
  6. 速い・遅い・失敗の3通りで表示を確認する

Reactのカードを読み込む間は、実際のカードと幅・画像比率・余白を揃えたスケルトンを置くと、読み込み後に内容の位置が変わりにくくなります。短い処理でも毎回スケルトンが点滅する場合は、表示だけを少し遅らせる方法があります。

すでに表示できている内容を更新する場面なら、古い内容を残すほうが読みやすいこともあります。この記事では、300ms遅らせるスケルトン、Suspenseのfallback、useTransitionで旧表示を保つ例を作ります。実行確認はReact 19.3.0で行っています。

初回の空き領域と、更新中の旧表示を分けて考える

読み込みの場面 この例で選ぶ表示 意図
まだカードの内容がない 寸法を確保し、待ち時間が長いときだけスケルトンを出す 読み込み後の位置を先に用意する
表示済みカードから、別のカードへ切り替える 可能なら旧カードを残し、「更新中」と伝える 読みかけの内容を突然消さない
小さなボタン操作の完了を待つ ボタン内の短い状態表示など カード全体を置き換える必要がない

カードの形が分かっている場面にスケルトンを使います。読み込み状態に合わせてスピナーを出し分ける基本は、Reactでローディングスピナーを表示する方法を参照してください。

スポンサーリンク

実カードとスケルトンで、同じレイアウトを使う

次のCards.jsxには、実カード、装飾用のスケルトン、状態を切り替える領域をまとめています。ここでのカードの色面は、画像領域のサイズを示すサンプルです。

import React from 'react';
import { useDelayedFallback } from './useDelayedFallback';
import './cards.css';

export function ProductCard({ title, description, tone = 'sea' }) {
  return (
    <article className="product-card" data-card="loaded">
      <div className={`product-card__media tone-${tone}`} aria-hidden="true" />
      <div className="product-card__body">
        <h3 className="product-card__title">{title}</h3>
        <p className="product-card__description">{description}</p>
      </div>
    </article>
  );
}

export function CardSkeleton() {
  return (
    <div className="product-card" aria-hidden="true" data-card="skeleton">
      <div className="product-card__media skeleton-block" />
      <div className="product-card__body">
        <div className="product-card__title skeleton-block" />
        <div className="product-card__description skeleton-block" />
      </div>
    </div>
  );
}

export function CardRegion({ loading = false, error = false, product }) {
  const showSkeleton = useDelayedFallback(loading, 300);

  return (
    <div className="card-region">
      <p className="card-status" role="status">{loading ? '読み込み中です' : ''}</p>
      <div aria-busy={loading}>
        {loading ? (
          <div className={showSkeleton ? '' : 'skeleton-reserved'}>
            <CardSkeleton />
          </div>
        ) : error ? (
          <p role="alert">読み込めませんでした。時間を置いて再度お試しください。</p>
        ) : product ? (
          <ProductCard {...product} />
        ) : (
          <p>表示するカードがありません。</p>
        )}
      </div>
    </div>
  );
}

実カードとスケルトンの両方が、product-card__mediaとproduct-card__bodyを使います。片方だけpaddingや画像比率を変えると、読み込み完了時に下の要素が動きます。

CardRegionへは、既存のデータ取得処理が持つ状態を渡します。例えば<CardRegion loading={isLoading} error={hasError} product={product} />という使い方です。通信が失敗したらloadingをfalseにし、errorをtrueにしてエラー表示へ移してください。

スタイルはcards.cssに保存します。タイトルと説明文は、この例ではそれぞれ2行分の高さを確保しています。

.product-card {
  width: 100%;
  max-width: 360px;
  box-sizing: border-box;
  overflow: hidden;
  border: 1px solid #d6dce4;
  border-radius: 12px;
  background: white;
  color: #17253a;
}
.product-card__media { width: 100%; aspect-ratio: 16 / 9; }
.tone-sea { background: linear-gradient(135deg, #93c5fd, #0e7490); }
.tone-mountain { background: linear-gradient(135deg, #bbf7d0, #166534); }
.product-card__body { display: grid; gap: 12px; padding: 16px; }
.product-card__title, .product-card__description {
  margin: 0;
  min-height: 3rem;
  line-height: 1.5rem;
  font-size: 1rem;
  overflow-wrap: anywhere;
}
.card-status { min-height: 1.5rem; line-height: 1.5rem; margin: 0 0 8px; }
.skeleton-reserved { visibility: hidden; }
.skeleton-block { position: relative; overflow: hidden; background: #e2e8f0; }
.skeleton-block::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, #ffffffb3, transparent);
  transform: translateX(-100%);
  animation: card-shimmer 1.4s linear infinite;
}
@keyframes card-shimmer { to { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
  .skeleton-block::after { animation: none; }
}

実際の文章が3行以上になるなら、その高さは増えます。スケルトンだけ寸法を固定しても、あらゆる内容でレイアウトの変化をゼロにはできません。実データの文字量に合わせて余白と高さを調整してください。画像を使う場合も、表示前に比率が分かるようにしておきます。

動く灰色の帯は装飾なので、スケルトン全体をaria-hidden="true"にしています。読み込み中であることは別のrole="status"で伝え、内容が入る領域にaria-busyを付けました。「動きを減らす」設定では帯のアニメーションを停止し、形だけ残します。CSSの別の見た目を試したい場合は、CSSローディングアニメーション集も利用できます。

300ms経っても読み込み中なら、スケルトンを見せる

useDelayedFallback.jsは、読み込み中になってから約300ms待って表示を有効にするフックです。完了した場合やコンポーネントを外した場合は、予約したタイマーを解除します。

import { useEffect, useState } from 'react';

export function useDelayedFallback(active, delayMs = 300) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (!active) {
      setVisible(false);
      return;
    }
    const timer = window.setTimeout(() => setVisible(true), delayMs);
    return () => window.clearTimeout(timer);
  }, [active, delayMs]);

  return active && visible;
}

CardRegionはこの戻り値がfalseの間、skeleton-reservedを付けてvisibility: hiddenにしています。display: noneと違い、見えなくてもカードの領域は残ります。「読み込み中です」の文字はすぐに表示し、灰色の形だけを遅らせる設計です。

最後のactive && visibleにより、読み込みが完了した描画ではスケルトンをすぐ非表示にします。完了後に古いタイマーが動いて表示を戻さないよう、Effectのcleanupも必要です。

300msはこのサンプルで選んだ値です。短い待ち時間の点滅を減らせますが、301msで完了するような境界付近の処理まで、点滅を完全になくせるわけではありません。最低表示時間を設ける方法もありますが、その分だけ取得済みの内容を見せるのが遅れます。更新画面では、次に紹介する旧表示の維持も検討できます。

Suspenseは、Effect内のfetchを自動では待たない

Suspenseのfallbackにも、同じスケルトン領域を使えます。ただし、useEffectで実行したfetchをSuspenseで囲むだけでは、fallbackは表示されません。その場合は先ほどのように、取得処理が管理するloadingを渡します。

以下では、Suspenseが扱える読み込みの例としてReact.lazyによるコンポーネントのコード読み込みを使います。データ取得をSuspenseへ連携する方式は、利用するフレームワークなどに合わせてください。対応する読み込みの種類はReact公式のSuspenseリファレンスに記載されています。

動的に読み込むMountainCard.jsxを用意します。このファイル自身にfetch処理はありません。

import React from 'react';
import { CardRegion } from './Cards';

export default function MountainCard() {
  return <CardRegion product={{ title: '山の散歩コース', description: '緑を眺めながら歩くコースの紹介です。', tone: 'mountain' }} />;
}

コードの読み込みが終わるまで、親の<Suspense fallback={<CardRegion loading />}>が代わりの領域を表示します。ここでもスケルトンの形が見えるまで300ms待つため、空白が詰まらないよう、fallbackと読み込み後の両方に同じCardRegionを使います。

useTransitionで、読み込み済みのカードを残す

次のTransitionCards.jsxは、海のカードを最初に表示し、ボタン操作で山のカードのコードを読み込みます。2つのボタンは、状態変更をTransitionにするかどうかだけを変えています。

import React, { lazy, Suspense, useState, useTransition } from 'react';
import { CardRegion } from './Cards';

const MountainCard = lazy(() => import('./MountainCard'));

export function TransitionCards() {
  const [view, setView] = useState('sea');
  const [isPending, startTransition] = useTransition();

  return (
    <section aria-label="Suspenseの例">
      <button type="button" onClick={() => startTransition(() => setView('mountain'))}>
        山のカードへ(旧表示を維持)
      </button>
      <button type="button" onClick={() => setView('mountain')}>
        山のカードへ(置き換え)
      </button>
      <p className="card-status" role="status">{isPending ? '更新中です(現在のカードを表示)' : ''}</p>
      <Suspense fallback={<CardRegion loading />}>
        {view === 'sea' ? (
          <CardRegion product={{ title: '海の散歩コース', description: '水辺を歩くコースの紹介です。' }} />
        ) : (
          <MountainCard />
        )}
      </Suspense>
    </section>
  );
}

「旧表示を維持」では、山のカードのコードが揃うまで海のカードが表示されたままになります。isPendingがtrueの間は、いま見えている内容が更新中だと伝えます。「置き換え」では通常の状態変更を行うため、読み込みに時間がかかればfallbackへ切り替わります。

これは、すでに内容を表示したSuspense境界での更新を扱う例です。最初から何も表示されていない領域に、useTransitionが旧内容を作ってくれるわけではありません。公式の不要なローディング表示を避ける例でも、この違いを確認できます。

lazy()はコンポーネントの外側で宣言します。また、デモでは同じSuspense境界を保っています。切り替えるたびに境界のkeyまで変えると、別の境界として作り直され、旧表示を維持する前提が変わります。lazyの宣言位置についてはReact公式のlazyリファレンスも参照してください。

一度読み込んだモジュールは次の切り替えで速く表示されるため、動作確認ではページを開き直し、必要ならブラウザのNetworkパネルで通信を遅くします。通信の遅延を本番コードへ埋め込む必要はありません。

速い・遅い・失敗の3通りで表示を確認する

スケルトンは、遅い通信だけを見ていると問題を見落としやすい部分です。今回の実行用サンプルでは、表示状態を80ms・900msのタイマーで作る検証と、実際のlazy importを遅らせる検証を分けて行いました。タイマーの値は通信性能の測定結果ではありません。

確認する場面 期待する状態
80msで読み込み完了 灰色のスケルトンを見せずに内容へ切り替わり、後からタイマーが復活しない
900msの待ち時間 300ms経過後にスケルトンが見え、完了したら実カードへ切り替わる
取得失敗 読み込み中を終え、エラーを伝える
タイマーが残る間に領域を削除 タイマーが解除される。再表示は新しい待ち時間から始まる
動きを減らす設定 形は残り、shimmerは停止する
lazy importを待つ通常の更新 fallbackが表示される
lazy importを待つTransition 旧カードと更新中の表示が残る

テストではReact StrictModeを有効にし、スケルトンと実カードの高さ・表示位置も比較しました。カードだけでなく、上に置いたステータス行のline-heightも揃える必要があります。文字があるときだけ行が高くなると、カード本体の寸法が同じでも位置がずれます。

最後に、読み込み中の表示が消えることだけでなく、実データ、空の結果、失敗のどれへ進んだかを確認しましょう。初回は領域を確保し、更新時は残せる内容を残す。その違いを先に決めると、スケルトンを置く範囲も絞れます。

スポンサーリンク