ゼロから作る!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 の useMotionValue
と useTransform
を活用して、プログレスに応じて複数のプロパティを同時にアニメーションする高度なローディングコンポーネントを実装します。
インターフェースとプロパティの定義:
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 アニメーションを活用
- 再利用性を確保:カスタムフックやコンポーネント化により、効率的な開発を実現
- アクセシビリティを考慮:スクリーンリーダー対応やキーボードナビゲーションを実装
- エラーハンドリングを徹底:メモリリークやパフォーマンス問題を事前に回避
効果的なローディングアニメーションの実装により、以下のような効果が期待できます:
- ユーザーの待機時間に対する満足度の向上
- ブランドイメージの強化
- 操作の継続性の確保
- エラー状態との明確な区別
今回紹介した手法を組み合わせることで、ユーザーが快適に利用できる高品質なローディング体験を提供できます。プロジェクトの要件に応じて適切な手法を選択し、ユーザー体験の向上に役立ててください。
関連リンク
- article
ゼロから作る!React でスムーズなローディングアニメーションを実装する方法
- article
ユーザー体験を劇的に変える!React で実装するマイクロインタラクション事例集
- article
CSS だけじゃ物足りない!React アプリを動かす JS アニメーション 10 選
- article
React × Three.js で実現するインタラクティブ 3D アニメーション最前線
- article
htmx と React/Vue/Svelte の違いを徹底比較
- article
あなたの Jotai アプリ、遅くない?React DevTools と debugLabel でボトルネックを特定し、最適化する手順
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実