T-CREATOR

ゼロから作る!React でスムーズなローディングアニメーションを実装する方法

ゼロから作る!React でスムーズなローディングアニメーションを実装する方法

ユーザー体験を左右する重要な要素であるローディングアニメーション。単なる「読み込み中」の表示ではなく、ユーザーに安心感を与え、待ち時間を快適にするための技術的な実装方法を詳しく解説いたします。React の特性を活かした効率的で美しいローディングアニメーションの作り方を、基礎から応用まで段階的に学んでいきましょう。

背景 - ローディングアニメーションの重要性

現代の Web アプリケーションにおいて、ローディングアニメーションは単なる装飾ではありません。ユーザーが操作を実行してから結果が表示されるまでの時間を、心理的に快適に過ごしてもらうための重要な UX 要素です。

ローディングアニメーションが果たす役割:

  • ユーザーの不安感を軽減:処理が進行中であることを明確に伝える
  • ブランドイメージの向上:洗練されたアニメーションでプロフェッショナルな印象を与える
  • 操作の継続性を保つ:ユーザーが操作を中断することなく待機できる
  • エラー状態との区別:正常な処理中であることを視覚的に表現する

特に React アプリケーションでは、コンポーネントの状態管理とアニメーションを組み合わせることで、より高度で柔軟なローディング体験を提供できます。

課題 - 従来のローディング実装の問題点

多くの開発者が直面するローディング実装の課題を整理してみましょう。

よくある問題点:

  • パフォーマンスの悪化:アニメーションが重く、逆にユーザー体験を損なう
  • 状態管理の複雑さ:複数のローディング状態を適切に管理できない
  • 再利用性の欠如:各コンポーネントで個別に実装し、コードの重複が発生
  • アクセシビリティの軽視:スクリーンリーダー対応が不十分
  • レスポンシブ対応の不備:デバイスによって表示が崩れる

実際の開発現場で発生するエラー例:

vbnetWarning: Can't perform a React state update on an unmounted component.
This is a memory leak, and it will show up in the console as a warning.
sqlError: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.

これらの問題を解決するため、React の特性を活かした体系的なアプローチが必要です。

解決策 - React でのローディングアニメーション実装手法

CSS アニメーションを活用した実装

最も基本的でパフォーマンスの良いアプローチが CSS アニメーションです。React コンポーネントと組み合わせることで、効率的なローディングアニメーションを実装できます。

基本的なスピナーコンポーネントの実装:

tsx// LoadingSpinner.tsx
import React from 'react';
import './LoadingSpinner.css';

interface LoadingSpinnerProps {
  size?: 'small' | 'medium' | 'large';
  color?: string;
  className?: string;
}

const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
  size = 'medium',
  color = '#007bff',
  className = '',
}) => {
  return (
    <div className={`loading-spinner ${size} ${className}`}>
      <div
        className='spinner-ring'
        style={{ borderColor: color }}
      />
    </div>
  );
};

export default LoadingSpinner;

対応する CSS アニメーション:

css/* LoadingSpinner.css */
.loading-spinner {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.spinner-ring {
  border: 3px solid transparent;
  border-top: 3px solid;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.loading-spinner.small .spinner-ring {
  width: 20px;
  height: 20px;
}

.loading-spinner.medium .spinner-ring {
  width: 32px;
  height: 32px;
}

.loading-spinner.large .spinner-ring {
  width: 48px;
  height: 48px;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

この実装により、サイズや色をカスタマイズ可能な再利用可能なスピナーコンポーネントが完成します。

React Hooks を使った状態管理

ローディング状態を効率的に管理するためのカスタムフックを実装します。

ローディング状態管理フック:

tsx// useLoadingState.ts
import { useState, useCallback, useRef } from 'react';

interface UseLoadingStateOptions {
  delay?: number;
  minDuration?: number;
}

export const useLoadingState = (
  options: UseLoadingStateOptions = {}
) => {
  const { delay = 0, minDuration = 0 } = options;
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const startTimeRef = useRef<number>(0);
  const timeoutRef = useRef<NodeJS.Timeout>();

  const startLoading = useCallback(() => {
    setError(null);
    startTimeRef.current = Date.now();

    if (delay > 0) {
      timeoutRef.current = setTimeout(() => {
        setIsLoading(true);
      }, delay);
    } else {
      setIsLoading(true);
    }
  }, [delay]);

  const stopLoading = useCallback(() => {
    const elapsed = Date.now() - startTimeRef.current;
    const remaining = Math.max(0, minDuration - elapsed);

    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    if (remaining > 0) {
      setTimeout(() => {
        setIsLoading(false);
      }, remaining);
    } else {
      setIsLoading(false);
    }
  }, [minDuration]);

  const withLoading = useCallback(
    async <T,>(asyncFn: () => Promise<T>): Promise<T> => {
      try {
        startLoading();
        const result = await asyncFn();
        return result;
      } catch (err) {
        setError(
          err instanceof Error
            ? err.message
            : 'Unknown error'
        );
        throw err;
      } finally {
        stopLoading();
      }
    },
    [startLoading, stopLoading]
  );

  return {
    isLoading,
    error,
    startLoading,
    stopLoading,
    withLoading,
  };
};

このフックにより、ローディング状態の管理が簡潔になり、最小表示時間や遅延表示などの高度な制御が可能になります。

Motion による高度なアニメーション

Motion は、Framer Motion から進化した最新のアニメーションライブラリです。パフォーマンスの大幅な改善と新機能が追加され、より洗練されたローディングアニメーションを実装できます。

パルス効果付きローディングコンポーネント:

tsx// PulseLoading.tsx
import React from 'react';
import { motion } from 'motion/react';

interface PulseLoadingProps {
  dots?: number;
  size?: number;
  color?: string;
  duration?: number;
}

const PulseLoading: React.FC<PulseLoadingProps> = ({
  dots = 3,
  size = 8,
  color = '#007bff',
  duration = 0.6,
}) => {
  const containerVariants = {
    animate: {
      transition: {
        staggerChildren: duration / dots,
        repeat: Infinity,
      },
    },
  };

  const dotVariants = {
    animate: {
      scale: [1, 1.5, 1],
      opacity: [0.5, 1, 0.5],
      transition: {
        duration,
        ease: 'easeInOut',
      },
    },
  };

  return (
    <motion.div
      className='pulse-loading'
      variants={containerVariants}
      animate='animate'
      style={{
        display: 'flex',
        gap: '4px',
        alignItems: 'center',
      }}
    >
      {Array.from({ length: dots }).map((_, index) => (
        <motion.div
          key={index}
          variants={dotVariants}
          style={{
            width: size,
            height: size,
            borderRadius: '50%',
            backgroundColor: color,
          }}
        />
      ))}
    </motion.div>
  );
};

export default PulseLoading;

Motion の新機能を活用した高度なローディング:

Motion の useMotionValueuseTransform を活用して、プログレスに応じて複数のプロパティを同時にアニメーションする高度なローディングコンポーネントを実装します。

インターフェースとプロパティの定義:

tsx// AdvancedLoading.tsx
import React from 'react';
import {
  motion,
  useMotionValue,
  useTransform,
} from 'motion/react';

interface AdvancedLoadingProps {
  progress?: number;
  size?: number;
  strokeWidth?: number;
  color?: string;
}

Motion 値と変換関数の設定:

プログレス値に基づいて、円の描画、スケール、透明度を動的に計算します。

tsxconst AdvancedLoading: React.FC<AdvancedLoadingProps> = ({
  progress = 0,
  size = 60,
  strokeWidth = 4,
  color = '#007bff',
}) => {
  // プログレス値を Motion 値として管理
  const progressValue = useMotionValue(progress);

  // 円の円周を計算
  const circumference =
    2 * Math.PI * ((size - strokeWidth) / 2);

  // プログレスに応じて円の描画オフセットを計算
  const strokeDashoffset = useTransform(
    progressValue,
    [0, 100],
    [circumference, 0]
  );

  // プログレスに応じてスケールを計算
  const scale = useTransform(
    progressValue,
    [0, 100],
    [0.8, 1]
  );

  // プログレスに応じて透明度を計算
  const opacity = useTransform(
    progressValue,
    [0, 100],
    [0.5, 1]
  );

  // プログレス値の更新
  React.useEffect(() => {
    progressValue.set(progress);
  }, [progress, progressValue]);

SVG 円形プログレスバーの実装:

SVG を使用して、背景円とプログレス円を描画します。

tsx  return (
    <motion.div
      style={{ scale, opacity }}
      className='advanced-loading'
    >
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
      >
        {/* 背景の円 */}
        <circle
          cx={size / 2}
          cy={size / 2}
          r={(size - strokeWidth) / 2}
          stroke='#e9ecef'
          strokeWidth={strokeWidth}
          fill='none'
        />

        {/* プログレス円 */}
        <motion.circle
          cx={size / 2}
          cy={size / 2}
          r={(size - strokeWidth) / 2}
          stroke={color}
          strokeWidth={strokeWidth}
          fill='none'
          strokeLinecap='round'
          strokeDasharray={circumference}
          style={{ strokeDashoffset }}
          initial={{ strokeDashoffset: circumference }}
          animate={{ strokeDashoffset }}
          transition={{ duration: 0.5, ease: 'easeInOut' }}
        />
      </svg>

プログレステキストの表示:

プログレス値をパーセンテージで表示し、フェードインアニメーションを適用します。

tsx      <motion.div
        className='loading-text'
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ delay: 0.2 }}
      >
        {Math.round(progress)}%
      </motion.div>
    </motion.div>
  );
};

export default AdvancedLoading;

Motion のインストール方法:

bash# Motion のインストール
yarn add motion

# React 用のインポート
import { motion } from 'motion/react';

よくあるエラーと解決法:

bash# Error: Cannot read properties of undefined (reading 'set')
# 原因: useMotionValueの初期化が適切でない
tsx// 解決法: useMotionValueの適切な初期化
const progressValue = useMotionValue(0); // 初期値を設定

useEffect(() => {
  if (progressValue) {
    progressValue.set(progress);
  }
}, [progress, progressValue]);

Motion では、ハイブリッドエンジンと Independent Transform の導入により、複雑なアニメーションでも 60FPS を維持できるようになりました。また、JavaScript、React、Vue の 3 つのプラットフォームをサポートしています。

SVG アニメーションの活用

SVG を使った高度なローディングアニメーションを実装します。

SVG ベースのローディングアニメーション:

tsx// SVGCircleLoading.tsx
import React from 'react';

interface SVGCircleLoadingProps {
  size?: number;
  strokeWidth?: number;
  color?: string;
  duration?: number;
}

const SVGCircleLoading: React.FC<SVGCircleLoadingProps> = ({
  size = 40,
  strokeWidth = 4,
  color = '#007bff',
  duration = 2,
}) => {
  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;

  return (
    <div className='svg-circle-loading'>
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
      >
        {/* 背景の円 */}
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          stroke='#e9ecef'
          strokeWidth={strokeWidth}
          fill='none'
        />

        {/* アニメーションする円 */}
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          stroke={color}
          strokeWidth={strokeWidth}
          fill='none'
          strokeLinecap='round'
          strokeDasharray={circumference}
          strokeDashoffset={circumference}
          style={{
            animation: `circle-loading ${duration}s linear infinite`,
          }}
        />
      </svg>

      <style jsx>{`
        @keyframes circle-loading {
          0% {
            stroke-dashoffset: ${circumference};
            transform: rotate(0deg);
          }
          50% {
            stroke-dashoffset: ${circumference * 0.25};
          }
          100% {
            stroke-dashoffset: 0;
            transform: rotate(360deg);
          }
        }
      `}</style>
    </div>
  );
};

export default SVGCircleLoading;

SVG アニメーションは、どのようなサイズでも鮮明に表示され、カスタマイズ性も高い特徴があります。

Skeleton ローディングの実装

コンテンツの構造を事前に示す Skeleton ローディングを実装します。

Skeleton コンポーネントの基本実装:

tsx// Skeleton.tsx
import React from 'react';
import './Skeleton.css';

interface SkeletonProps {
  width?: string | number;
  height?: string | number;
  borderRadius?: string;
  className?: string;
  animation?: 'pulse' | 'wave' | 'none';
}

const Skeleton: React.FC<SkeletonProps> = ({
  width = '100%',
  height = '20px',
  borderRadius = '4px',
  className = '',
  animation = 'pulse',
}) => {
  const style = {
    width: typeof width === 'number' ? `${width}px` : width,
    height:
      typeof height === 'number' ? `${height}px` : height,
    borderRadius,
  };

  return (
    <div
      className={`skeleton ${animation} ${className}`}
      style={style}
    />
  );
};

export default Skeleton;

Skeleton 用の CSS スタイル:

css/* Skeleton.css */
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: skeleton-loading 1.5s infinite;
}

.skeleton.pulse {
  animation: skeleton-pulse 1.5s ease-in-out infinite;
}

.skeleton.wave {
  animation: skeleton-wave 1.5s ease-in-out infinite;
}

@keyframes skeleton-loading {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

@keyframes skeleton-pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

@keyframes skeleton-wave {
  0% {
    transform: translateX(-100%);
  }
  100% {
    transform: translateX(100%);
  }
}

Skeleton を使ったカードローディング:

tsx// SkeletonCard.tsx
import React from 'react';
import Skeleton from './Skeleton';

const SkeletonCard: React.FC = () => {
  return (
    <div className='skeleton-card'>
      <Skeleton
        width={200}
        height={120}
        borderRadius='8px'
        className='skeleton-image'
      />
      <div className='skeleton-content'>
        <Skeleton
          width='80%'
          height={20}
          className='skeleton-title'
        />
        <Skeleton
          width='60%'
          height={16}
          className='skeleton-subtitle'
        />
        <Skeleton
          width='100%'
          height={14}
          className='skeleton-text'
        />
        <Skeleton
          width='100%'
          height={14}
          className='skeleton-text'
        />
      </div>
    </div>
  );
};

export default SkeletonCard;

プログレスバーの実装

進捗状況を視覚的に表示するプログレスバーを実装します。

アニメーション付きプログレスバー:

tsx// AnimatedProgressBar.tsx
import React, { useEffect, useState } from 'react';
import './AnimatedProgressBar.css';

interface AnimatedProgressBarProps {
  progress: number;
  duration?: number;
  color?: string;
  height?: number;
  showPercentage?: boolean;
  className?: string;
}

const AnimatedProgressBar: React.FC<
  AnimatedProgressBarProps
> = ({
  progress,
  duration = 1000,
  color = '#007bff',
  height = 8,
  showPercentage = false,
  className = '',
}) => {
  const [animatedProgress, setAnimatedProgress] =
    useState(0);

  useEffect(() => {
    const startTime = Date.now();
    const startProgress = animatedProgress;
    const progressDiff = progress - startProgress;

    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progressRatio = Math.min(elapsed / duration, 1);

      const currentProgress =
        startProgress + progressDiff * progressRatio;
      setAnimatedProgress(currentProgress);

      if (progressRatio < 1) {
        requestAnimationFrame(animate);
      }
    };

    requestAnimationFrame(animate);
  }, [progress, duration, animatedProgress]);

  return (
    <div className={`animated-progress-bar ${className}`}>
      <div
        className='progress-track'
        style={{ height: `${height}px` }}
      >
        <div
          className='progress-fill'
          style={{
            width: `${animatedProgress}%`,
            backgroundColor: color,
            height: `${height}px`,
          }}
        />
      </div>
      {showPercentage && (
        <span className='progress-text'>
          {Math.round(animatedProgress)}%
        </span>
      )}
    </div>
  );
};

export default AnimatedProgressBar;

プログレスバー用の CSS:

css/* AnimatedProgressBar.css */
.animated-progress-bar {
  display: flex;
  align-items: center;
  gap: 12px;
}

.progress-track {
  flex: 1;
  background-color: #e9ecef;
  border-radius: 4px;
  overflow: hidden;
  position: relative;
}

.progress-fill {
  border-radius: 4px;
  transition: width 0.3s ease;
  position: relative;
}

.progress-fill::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: linear-gradient(
    90deg,
    transparent,
    rgba(255, 255, 255, 0.3),
    transparent
  );
  animation: progress-shine 2s infinite;
}

.progress-text {
  font-size: 14px;
  font-weight: 500;
  color: #6c757d;
  min-width: 40px;
  text-align: right;
}

@keyframes progress-shine {
  0% {
    transform: translateX(-100%);
  }
  100% {
    transform: translateX(100%);
  }
}

スピナー・ローダーの実装

様々なスタイルのスピナーコンポーネントを実装します。

多様なスピナータイプ:

複数のスピナータイプを一つのコンポーネントで管理し、用途に応じて使い分けられるようにします。

コンポーネントの基本構造:

tsx// MultiSpinner.tsx
import React from 'react';
import './MultiSpinner.css';

type SpinnerType = 'dots' | 'bars' | 'ripple' | 'cube';

interface MultiSpinnerProps {
  type: SpinnerType;
  size?: number;
  color?: string;
  className?: string;
}

スピナータイプ別のレンダリング関数:

各スピナータイプに応じた異なるアニメーション効果を実装します。

tsxconst MultiSpinner: React.FC<MultiSpinnerProps> = ({
  type,
  size = 32,
  color = '#007bff',
  className = '',
}) => {
  const renderSpinner = () => {
    switch (type) {
      case 'dots':
        return (
          <div className='dots-spinner'>
            {[0, 1, 2].map((i) => (
              <div
                key={i}
                className='dot'
                style={{
                  backgroundColor: color,
                  animationDelay: `${i * 0.2}s`,
                }}
              />
            ))}
          </div>
        );

      case 'bars':
        return (
          <div className='bars-spinner'>
            {[0, 1, 2, 3].map((i) => (
              <div
                key={i}
                className='bar'
                style={{
                  backgroundColor: color,
                  animationDelay: `${i * 0.1}s`,
                }}
              />
            ))}
          </div>
        );

      case 'ripple':
        return (
          <div className='ripple-spinner'>
            <div
              className='ripple-circle'
              style={{ borderColor: color }}
            />
            <div
              className='ripple-circle'
              style={{
                borderColor: color,
                animationDelay: '0.5s',
              }}
            />
          </div>
        );

      case 'cube':
        return (
          <div className='cube-spinner'>
            <div
              className='cube'
              style={{ backgroundColor: color }}
            />
          </div>
        );

      default:
        return null;
    }
  };

コンポーネントの出力:

サイズとクラス名を適用して、選択されたスピナータイプを表示します。

tsx  return (
    <div
      className={`multi-spinner ${className}`}
      style={{ width: size, height: size }}
    >
      {renderSpinner()}
    </div>
  );
};

export default MultiSpinner;

スピナー用の CSS アニメーション:

各スピナータイプに対応する CSS アニメーションを定義します。アニメーションの遅延を活用して、要素が順番に動く効果を実現します。

基本レイアウトとドットスピナー:

ドットが順番にバウンスするアニメーションを実装します。

css/* MultiSpinner.css */
.multi-spinner {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Dots Spinner */
.dots-spinner {
  display: flex;
  gap: 4px;
}

.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  animation: dots-bounce 1.4s ease-in-out infinite both;
}

@keyframes dots-bounce {
  0%,
  80%,
  100% {
    transform: scale(0);
  }
  40% {
    transform: scale(1);
  }
}

バースピナーとリップルスピナー:

バーが伸縮するアニメーションと、円が拡大しながらフェードアウトするリップル効果を実装します。

css/* Bars Spinner */
.bars-spinner {
  display: flex;
  gap: 2px;
  align-items: center;
}

.bar {
  width: 3px;
  height: 100%;
  animation: bars-stretch 1.2s ease-in-out infinite;
}

@keyframes bars-stretch {
  0%,
  40%,
  100% {
    transform: scaleY(0.4);
  }
  20% {
    transform: scaleY(1);
  }
}

/* Ripple Spinner */
.ripple-spinner {
  position: relative;
}

.ripple-circle {
  position: absolute;
  border: 2px solid;
  border-radius: 50%;
  animation: ripple 1.5s linear infinite;
}

.ripple-circle:nth-child(1) {
  width: 100%;
  height: 100%;
}

.ripple-circle:nth-child(2) {
  width: 60%;
  height: 60%;
  top: 20%;
  left: 20%;
}

@keyframes ripple {
  0% {
    transform: scale(0);
    opacity: 1;
  }
  100% {
    transform: scale(1);
    opacity: 0;
  }
}

キューブスピナー:

3D 回転を使用して、キューブが立体的に回転するアニメーションを実装します。

css/* Cube Spinner */
.cube-spinner {
  perspective: 100px;
}

.cube {
  width: 100%;
  height: 100%;
  animation: cube-rotate 1.2s infinite linear;
  transform-style: preserve-3d;
}

@keyframes cube-rotate {
  0% {
    transform: rotateX(0deg) rotateY(0deg);
  }
  100% {
    transform: rotateX(360deg) rotateY(360deg);
  }
}

フェードイン・フェードアウト効果

コンテンツの表示・非表示を滑らかに行うフェード効果を実装します。

フェードトランジションコンポーネント:

tsx// FadeTransition.tsx
import React, { useState, useEffect } from 'react';
import './FadeTransition.css';

interface FadeTransitionProps {
  children: React.ReactNode;
  isVisible: boolean;
  duration?: number;
  delay?: number;
  className?: string;
}

const FadeTransition: React.FC<FadeTransitionProps> = ({
  children,
  isVisible,
  duration = 300,
  delay = 0,
  className = '',
}) => {
  const [shouldRender, setShouldRender] =
    useState(isVisible);
  const [isAnimating, setIsAnimating] = useState(false);

  useEffect(() => {
    if (isVisible) {
      setShouldRender(true);
      setIsAnimating(true);
    } else {
      setIsAnimating(false);
      const timer = setTimeout(() => {
        setShouldRender(false);
      }, duration);
      return () => clearTimeout(timer);
    }
  }, [isVisible, duration]);

  if (!shouldRender) return null;

  return (
    <div
      className={`fade-transition ${
        isAnimating ? 'fade-in' : 'fade-out'
      } ${className}`}
      style={{
        transitionDuration: `${duration}ms`,
        transitionDelay: `${delay}ms`,
      }}
    >
      {children}
    </div>
  );
};

export default FadeTransition;

フェードトランジション用の CSS:

css/* FadeTransition.css */
.fade-transition {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.fade-transition.fade-in {
  opacity: 1;
  transform: translateY(0);
}

.fade-transition.fade-out {
  opacity: 0;
  transform: translateY(-10px);
}

/* フェードインのバリエーション */
.fade-transition.fade-in-left {
  transform: translateX(-20px);
}

.fade-transition.fade-in-right {
  transform: translateX(20px);
}

.fade-transition.fade-in-up {
  transform: translateY(20px);
}

.fade-transition.fade-in-down {
  transform: translateY(-20px);
}

スケルトンローディングの実装

より高度なスケルトンローディングシステムを実装します。

スケルトンローディングシステム:

複雑なレイアウトに対応するスケルトンローディングシステムを実装します。テキスト、画像、アバター、ボタンなど、様々な要素のスケルトンを動的に生成できます。

インターフェースとプロパティの定義:

スケルトンアイテムの種類とプロパティを定義します。

tsx// SkeletonLoading.tsx
import React from 'react';
import Skeleton from './Skeleton';

interface SkeletonItem {
  type: 'text' | 'image' | 'avatar' | 'button';
  width?: string | number;
  height?: string | number;
  lines?: number;
}

interface SkeletonLoadingProps {
  items: SkeletonItem[];
  className?: string;
}

スケルトンアイテムのレンダリング関数:

各アイテムタイプに応じたスケルトンを生成します。

tsxconst SkeletonLoading: React.FC<SkeletonLoadingProps> = ({
  items,
  className = '',
}) => {
  const renderSkeletonItem = (
    item: SkeletonItem,
    index: number
  ) => {
    switch (item.type) {
      case 'text':
        return (
          <div key={index} className='skeleton-text-group'>
            {Array.from({ length: item.lines || 1 }).map(
              (_, lineIndex) => (
                <Skeleton
                  key={lineIndex}
                  width={
                    lineIndex === (item.lines || 1) - 1
                      ? '80%'
                      : '100%'
                  }
                  height={16}
                  className='skeleton-text-line'
                />
              )
            )}
          </div>
        );

      case 'image':
        return (
          <Skeleton
            key={index}
            width={item.width || 200}
            height={item.height || 150}
            borderRadius='8px'
            className='skeleton-image'
          />
        );

      case 'avatar':
        return (
          <Skeleton
            key={index}
            width={item.width || 40}
            height={item.height || 40}
            borderRadius='50%'
            className='skeleton-avatar'
          />
        );

      case 'button':
        return (
          <Skeleton
            key={index}
            width={item.width || 120}
            height={item.height || 36}
            borderRadius='6px'
            className='skeleton-button'
          />
        );

      default:
        return null;
    }
  };

コンポーネントの出力:

定義されたアイテム配列に基づいてスケルトンを表示します。

tsx  return (
    <div className={`skeleton-loading ${className}`}>
      {items.map((item, index) =>
        renderSkeletonItem(item, index)
      )}
    </div>
  );
};

export default SkeletonLoading;

スケルトンローディングの使用例:

tsx// SkeletonCardExample.tsx
import React from 'react';
import SkeletonLoading from './SkeletonLoading';

const SkeletonCardExample: React.FC = () => {
  const cardSkeletonItems = [
    { type: 'image' as const, width: 300, height: 200 },
    { type: 'text' as const, lines: 2 },
    { type: 'text' as const, lines: 1, width: '60%' },
    { type: 'button' as const, width: 100, height: 32 },
  ];

  return (
    <div className='skeleton-card-example'>
      <SkeletonLoading items={cardSkeletonItems} />
    </div>
  );
};

export default SkeletonCardExample;

カスタムフックによる再利用可能な実装

ローディング状態を効率的に管理するカスタムフックを実装します。

高度なローディング管理フック:

複雑なローディング状態を管理する高度なカスタムフックを実装します。遅延表示、最小表示時間、リトライ機能、プログレス管理などの機能を提供します。

インターフェースと設定の定義:

ローディング設定と状態の型定義を行います。

tsx// useAdvancedLoading.ts
import {
  useState,
  useCallback,
  useRef,
  useEffect,
} from 'react';

interface LoadingConfig {
  delay?: number;
  minDuration?: number;
  retryCount?: number;
  retryDelay?: number;
}

interface LoadingState {
  isLoading: boolean;
  error: string | null;
  retryCount: number;
  progress: number;
}

フックの初期化と状態管理:

設定値の初期化とローディング状態の管理を行います。

tsxexport const useAdvancedLoading = (
  config: LoadingConfig = {}
) => {
  const {
    delay = 0,
    minDuration = 0,
    retryCount: maxRetryCount = 3,
    retryDelay = 1000,
  } = config;

  const [state, setState] = useState<LoadingState>({
    isLoading: false,
    error: null,
    retryCount: 0,
    progress: 0,
  });

  const startTimeRef = useRef<number>(0);
  const timeoutRef = useRef<NodeJS.Timeout>();
  const progressIntervalRef = useRef<NodeJS.Timeout>();

プログレス更新とローディング開始関数:

プログレス値の更新とローディング開始時の処理を実装します。

tsxconst updateProgress = useCallback((progress: number) => {
  setState((prev) => ({ ...prev, progress }));
}, []);

const startLoading = useCallback(() => {
  setState((prev) => ({
    ...prev,
    isLoading: true,
    error: null,
    progress: 0,
  }));

  startTimeRef.current = Date.now();

  if (delay > 0) {
    timeoutRef.current = setTimeout(() => {
      setState((prev) => ({ ...prev, isLoading: true }));
    }, delay);
  }

  // プログレスバーのシミュレーション
  progressIntervalRef.current = setInterval(() => {
    setState((prev) => ({
      ...prev,
      progress: Math.min(
        prev.progress + Math.random() * 10,
        90
      ),
    }));
  }, 200);
}, [delay]);

ローディング停止とエラーハンドリング:

ローディング停止時の処理とエラー管理機能を実装します。

tsxconst stopLoading = useCallback(
  (success = true) => {
    const elapsed = Date.now() - startTimeRef.current;
    const remaining = Math.max(0, minDuration - elapsed);

    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    if (progressIntervalRef.current) {
      clearInterval(progressIntervalRef.current);
    }

    if (success) {
      updateProgress(100);
    }

    if (remaining > 0) {
      setTimeout(() => {
        setState((prev) => ({
          ...prev,
          isLoading: false,
          progress: 0,
        }));
      }, remaining);
    } else {
      setState((prev) => ({
        ...prev,
        isLoading: false,
        progress: 0,
      }));
    }
  },
  [minDuration, updateProgress]
);

const setError = useCallback(
  (error: string) => {
    setState((prev) => ({
      ...prev,
      error,
      retryCount: prev.retryCount + 1,
    }));
    stopLoading(false);
  },
  [stopLoading]
);

リトライ機能と非同期処理のラッパー:

リトライ機能と非同期処理をローディング状態と組み合わせる機能を実装します。

tsxconst retry = useCallback(
  async <T,>(asyncFn: () => Promise<T>): Promise<T> => {
    if (state.retryCount >= maxRetryCount) {
      throw new Error('Max retry count exceeded');
    }

    return new Promise((resolve, reject) => {
      setTimeout(async () => {
        try {
          const result = await asyncFn();
          resolve(result);
        } catch (error) {
          reject(error);
        }
      }, retryDelay);
    });
  },
  [state.retryCount, maxRetryCount, retryDelay]
);

const withLoading = useCallback(
  async <T,>(asyncFn: () => Promise<T>): Promise<T> => {
    try {
      startLoading();
      const result = await asyncFn();
      stopLoading(true);
      return result;
    } catch (error) {
      setError(
        error instanceof Error
          ? error.message
          : 'Unknown error'
      );
      throw error;
    }
  },
  [startLoading, stopLoading, setError]
);

クリーンアップと戻り値:

メモリリークを防ぐためのクリーンアップ処理と、フックの戻り値を定義します。

tsx  // クリーンアップ
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      if (progressIntervalRef.current) {
        clearInterval(progressIntervalRef.current);
      }
    };
  }, []);

  return {
    ...state,
    startLoading,
    stopLoading,
    setError,
    retry,
    withLoading,
    updateProgress,
  };
};

具体例 - 実装コードとベストプラクティス

実装時によく発生するエラーと対処法

Memory Leak Warning

vbnetWarning: Can't perform a React state update on an unmounted component.
This is a memory leak, and it will show up in the console as a warning.

対処法:

tsx// コンポーネントのアンマウント状態を追跡
const useIsMounted = () => {
  const isMountedRef = useRef(true);

  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  return isMountedRef;
};

// 使用例
const MyComponent = () => {
  const isMounted = useIsMounted();
  const [data, setData] = useState(null);

  const fetchData = async () => {
    try {
      const result = await api.getData();
      if (isMounted.current) {
        setData(result);
      }
    } catch (error) {
      if (isMounted.current) {
        console.error('Error fetching data:', error);
      }
    }
  };
};

Maximum Update Depth Error

sqlError: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.

対処法:

tsx// useEffect の依存配列を適切に設定
const [loading, setLoading] = useState(false);

useEffect(() => {
  if (shouldLoad) {
    setLoading(true);
    fetchData().finally(() => {
      setLoading(false);
    });
  }
}, [shouldLoad]); // 依存配列を明確に指定

Animation Performance Issues

vbnetWarning: React does not recognize the `animation` prop on a DOM element.

対処法:

tsx// CSS クラスを使用してアニメーションを制御
const LoadingComponent = ({ isAnimating }) => {
  return (
    <div
      className={`loading-component ${
        isAnimating ? 'animate' : ''
      }`}
    >
      Content
    </div>
  );
};

パフォーマンス最適化のベストプラクティス

React.memo を使用した最適化:

tsx// ローディングコンポーネントの最適化
const OptimizedLoadingSpinner =
  React.memo<LoadingSpinnerProps>(
    ({ size, color, className }) => {
      return (
        <div
          className={`loading-spinner ${size} ${className}`}
        >
          <div
            className='spinner-ring'
            style={{ borderColor: color }}
          />
        </div>
      );
    }
  );

OptimizedLoadingSpinner.displayName =
  'OptimizedLoadingSpinner';

useCallback を使用した関数の最適化:

tsx// ローディング状態の更新関数を最適化
const LoadingManager = () => {
  const [loadingStates, setLoadingStates] = useState({});

  const updateLoadingState = useCallback(
    (key: string, isLoading: boolean) => {
      setLoadingStates((prev) => ({
        ...prev,
        [key]: isLoading,
      }));
    },
    []
  );

  const startLoading = useCallback(
    (key: string) => {
      updateLoadingState(key, true);
    },
    [updateLoadingState]
  );

  const stopLoading = useCallback(
    (key: string) => {
      updateLoadingState(key, false);
    },
    [updateLoadingState]
  );

  return { loadingStates, startLoading, stopLoading };
};

アクセシビリティ対応

スクリーンリーダー対応:

tsx// アクセシブルなローディングコンポーネント
const AccessibleLoadingSpinner = ({
  size = 'medium',
  color = '#007bff',
  'aria-label': ariaLabel = 'Loading...',
}) => {
  return (
    <div
      role='status'
      aria-label={ariaLabel}
      aria-live='polite'
      className={`loading-spinner ${size}`}
    >
      <div
        className='spinner-ring'
        style={{ borderColor: color }}
        aria-hidden='true'
      />
      <span className='sr-only'>{ariaLabel}</span>
    </div>
  );
};

キーボードナビゲーション対応:

tsx// キーボード操作に対応したローディングオーバーレイ
const LoadingOverlay = ({ isVisible, onCancel }) => {
  const handleKeyDown = useCallback(
    (event: KeyboardEvent) => {
      if (event.key === 'Escape' && onCancel) {
        onCancel();
      }
    },
    [onCancel]
  );

  useEffect(() => {
    if (isVisible) {
      document.addEventListener('keydown', handleKeyDown);
      return () =>
        document.removeEventListener(
          'keydown',
          handleKeyDown
        );
    }
  }, [isVisible, handleKeyDown]);

  if (!isVisible) return null;

  return (
    <div
      role='dialog'
      aria-modal='true'
      aria-label='Loading content'
      className='loading-overlay'
      tabIndex={-1}
    >
      <LoadingSpinner />
      <button
        onClick={onCancel}
        className='loading-cancel-button'
        aria-label='Cancel loading'
      >
        Cancel
      </button>
    </div>
  );
};

まとめ

React でスムーズなローディングアニメーションを実装する方法について、基礎から応用まで詳しく解説いたしました。適切なローディングアニメーションは、ユーザー体験を劇的に向上させ、アプリケーションの品質を高める重要な要素です。

実装のポイント:

  • パフォーマンスを重視:CSS アニメーションを基本とし、必要に応じて JavaScript アニメーションを活用
  • 再利用性を確保:カスタムフックやコンポーネント化により、効率的な開発を実現
  • アクセシビリティを考慮:スクリーンリーダー対応やキーボードナビゲーションを実装
  • エラーハンドリングを徹底:メモリリークやパフォーマンス問題を事前に回避

効果的なローディングアニメーションの実装により、以下のような効果が期待できます:

  • ユーザーの待機時間に対する満足度の向上
  • ブランドイメージの強化
  • 操作の継続性の確保
  • エラー状態との明確な区別

今回紹介した手法を組み合わせることで、ユーザーが快適に利用できる高品質なローディング体験を提供できます。プロジェクトの要件に応じて適切な手法を選択し、ユーザー体験の向上に役立ててください。

関連リンク