React 18 × Jotai 完全対応ガイド - Suspense との連携方法

React の世界で状態管理ライブラリとして注目を集めている Jotai ですが、React 18 の登場により、その真価がさらに発揮されるようになりました。特に Suspense との連携は、これまでの開発体験を劇的に変化させる革新的な機能です。
従来の非同期処理では、ローディング状態やエラー状態の管理が煩雑でした。しかし、Jotai の Async Atom と React 18 の Suspense を組み合わせることで、まるで同期処理のようにシンプルで美しい非同期処理が実現できるのです。本記事では、この魔法のような連携方法を徹底的に解説し、あなたの開発スキルを次のレベルへと押し上げます。
React 18 の Concurrent Features、Suspense for Data Fetching、そして Jotai の強力な atom システム。これらの技術を組み合わせることで、パフォーマンスが高く、保守性に優れた現代的なアプリケーションを構築できます。実際のプロジェクトで即座に活用できる実践的なテクニックを、豊富なコード例とともにお届けしていきましょう。
Jotai の非同期処理パターン集
データ取得の全パターン
単純な API 呼び出し
Jotai での最もシンプルな非同期データ取得は、Async Atom を使用した直接的な API 呼び出しです。React 18 の Suspense と組み合わせることで、ローディング状態を意識することなく、データを扱えます。
typescriptimport { atom } from 'jotai';
import { Suspense } from 'react';
// 基本的なAsync Atom
const userDataAtom = atom(async () => {
const response = await fetch('/api/user/profile');
if (!response.ok) {
throw new Error(
`API呼び出しに失敗: ${response.status}`
);
}
return response.json();
});
// Suspenseと組み合わせたコンポーネント
function UserProfile() {
const [userData] = useAtom(userDataAtom);
return (
<div className='user-profile'>
<h2>{userData.name}</h2>
<p>Email: {userData.email}</p>
<p>
登録日:{' '}
{new Date(userData.createdAt).toLocaleDateString()}
</p>
</div>
);
}
// アプリケーションでの使用
function App() {
return (
<div>
<h1>ユーザー情報</h1>
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
</div>
);
}
// スケルトンUI
function UserProfileSkeleton() {
return (
<div className='user-profile skeleton'>
<div className='skeleton-title'></div>
<div className='skeleton-text'></div>
<div className='skeleton-text'></div>
</div>
);
}
この基本パターンの美しさは、UserProfile
コンポーネント内でローディング状態を一切考慮する必要がないことです。Suspense が自動的にローディング中は fallback
を表示し、データが取得できればコンポーネントをレンダリングします。
依存関係のあるデータ取得
実際のアプリケーションでは、あるデータを取得してから、そのデータを使って別のデータを取得するという連鎖的な処理が頻繁に発生します。Jotai はこのような依存関係のあるデータ取得を非常にエレガントに処理できます。
typescript// ユーザーIDを管理するatom
const currentUserIdAtom = atom<number | null>(null);
// ユーザー基本情報を取得するatom
const userBasicInfoAtom = atom(async (get) => {
const userId = get(currentUserIdAtom);
if (!userId) {
throw new Error('ユーザーIDが設定されていません');
}
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('ユーザー情報の取得に失敗しました');
}
return response.json();
});
// ユーザーの投稿一覧を取得するatom(ユーザー情報に依存)
const userPostsAtom = atom(async (get) => {
const userInfo = await get(userBasicInfoAtom);
const response = await fetch(
`/api/users/${userInfo.id}/posts`
);
if (!response.ok) {
throw new Error('投稿一覧の取得に失敗しました');
}
return response.json();
});
// ユーザーの詳細統計を取得するatom(投稿データに依存)
const userStatsAtom = atom(async (get) => {
const [userInfo, posts] = await Promise.all([
get(userBasicInfoAtom),
get(userPostsAtom),
]);
const response = await fetch(
`/api/users/${userInfo.id}/stats`
);
const stats = await response.json();
return {
...stats,
totalPosts: posts.length,
averagePostLength:
posts.reduce(
(sum, post) => sum + post.content.length,
0
) / posts.length,
};
});
// 使用例
function UserDashboard() {
const [userInfo] = useAtom(userBasicInfoAtom);
const [posts] = useAtom(userPostsAtom);
const [stats] = useAtom(userStatsAtom);
return (
<div className='user-dashboard'>
<header>
<h1>{userInfo.name}のダッシュボード</h1>
<p>フォロワー数: {stats.followers}</p>
</header>
<section className='posts-section'>
<h2>最新の投稿 ({posts.length}件)</h2>
{posts.slice(0, 5).map((post) => (
<article key={post.id} className='post-card'>
<h3>{post.title}</h3>
<p>{post.content.substring(0, 100)}...</p>
</article>
))}
</section>
<aside className='stats-sidebar'>
<h3>統計情報</h3>
<ul>
<li>総投稿数: {stats.totalPosts}</li>
<li>
平均文字数:{' '}
{Math.round(stats.averagePostLength)}
</li>
<li>総いいね数: {stats.totalLikes}</li>
</ul>
</aside>
</div>
);
}
並列データ取得の最適化
関連のない複数のデータを同時に取得する場合、並列処理により大幅な時間短縮が可能です。Jotai では、複数の Async Atom を組み合わせて効率的な並列データ取得を実現できます。
typescript// 独立したデータ取得atom群
const userProfileAtom = atom(async () => {
const response = await fetch('/api/user/profile');
return response.json();
});
const notificationsAtom = atom(async () => {
const response = await fetch('/api/notifications');
return response.json();
});
const friendsListAtom = atom(async () => {
const response = await fetch('/api/friends');
return response.json();
});
const recentActivitiesAtom = atom(async () => {
const response = await fetch('/api/activities/recent');
return response.json();
});
// 並列データ取得を管理するatom
const dashboardDataAtom = atom(async (get) => {
// Promise.allで並列実行
const [profile, notifications, friends, activities] =
await Promise.all([
get(userProfileAtom),
get(notificationsAtom),
get(friendsListAtom),
get(recentActivitiesAtom),
]);
return {
profile,
notifications,
friends,
activities,
loadedAt: new Date().toISOString(),
};
});
// 段階的データ表示のためのatom
const priorityDataAtom = atom(async (get) => {
// 重要なデータを先に取得
const profile = await get(userProfileAtom);
// 残りは並列で取得
const [notifications, friends] = await Promise.all([
get(notificationsAtom),
get(friendsListAtom),
]);
return { profile, notifications, friends };
});
// 使用例:段階的ローディング
function Dashboard() {
return (
<div className='dashboard'>
<Suspense fallback={<DashboardSkeleton />}>
<PriorityContent />
<Suspense fallback={<SecondaryContentSkeleton />}>
<SecondaryContent />
</Suspense>
</Suspense>
</div>
);
}
function PriorityContent() {
const [priorityData] = useAtom(priorityDataAtom);
return (
<div className='priority-content'>
<UserHeader profile={priorityData.profile} />
<NotificationsList
notifications={priorityData.notifications}
/>
</div>
);
}
function SecondaryContent() {
const [activities] = useAtom(recentActivitiesAtom);
return (
<div className='secondary-content'>
<ActivitiesFeed activities={activities} />
</div>
);
}
キャッシュ戦略と SWR パターン
データの鮮度管理は現代的なアプリケーションにとって重要な課題です。Jotai では、SWR(Stale-While-Revalidate)パターンを実装して、効率的なキャッシュ戦略を構築できます。
typescriptimport { atomWithStorage } from 'jotai/utils';
// キャッシュ付きデータ取得の実装
function createCachedAtom<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 5 * 60 * 1000
) {
// ローカルストレージにキャッシュデータを保存
const cacheAtom = atomWithStorage<{
data: T | null;
timestamp: number;
isStale: boolean;
} | null>(`cache_${key}`, null);
// データ取得のメインatom
const dataAtom = atom(async (get) => {
const cached = get(cacheAtom);
const now = Date.now();
// キャッシュが存在し、まだ新しい場合
if (
cached &&
cached.data &&
now - cached.timestamp < ttl
) {
return cached.data;
}
// キャッシュが古い場合は新しいデータを取得
try {
const freshData = await fetcher();
// キャッシュを更新
get((set) => {
set(cacheAtom, {
data: freshData,
timestamp: now,
isStale: false,
});
});
return freshData;
} catch (error) {
// エラー時は古いキャッシュがあれば返す
if (cached && cached.data) {
console.warn(
'新しいデータ取得に失敗、キャッシュデータを使用:',
error
);
return cached.data;
}
throw error;
}
});
// 強制リフレッシュ用のatom
const refreshAtom = atom(null, async (get, set) => {
try {
const freshData = await fetcher();
set(cacheAtom, {
data: freshData,
timestamp: Date.now(),
isStale: false,
});
return freshData;
} catch (error) {
console.error(
'データの強制リフレッシュに失敗:',
error
);
throw error;
}
});
return { dataAtom, refreshAtom, cacheAtom };
}
// 使用例
const {
dataAtom: userDataAtom,
refreshAtom: refreshUserDataAtom,
} = createCachedAtom(
'user-data',
() => fetch('/api/user').then((res) => res.json()),
10 * 60 * 1000 // 10分間キャッシュ
);
function UserProfile() {
const [userData] = useAtom(userDataAtom);
const [, refreshUserData] = useAtom(refreshUserDataAtom);
const handleRefresh = async () => {
try {
await refreshUserData();
} catch (error) {
console.error('更新に失敗しました:', error);
}
};
return (
<div className='user-profile'>
<div className='profile-header'>
<h2>{userData.name}</h2>
<button
onClick={handleRefresh}
className='refresh-btn'
>
更新
</button>
</div>
<div className='profile-details'>
<p>Email: {userData.email}</p>
<p>
最終更新:{' '}
{new Date(userData.updatedAt).toLocaleString()}
</p>
</div>
</div>
);
}
Suspense を活用した UX 向上
ローディング状態の美しい表現
React 18 の Suspense と Jotai を組み合わせることで、従来のローディングスピナーを超えた、美しく機能的なローディング体験を提供できます。
typescript// 段階的ローディングのためのカスタムコンポーネント
function ProgressiveSuspense({
children,
fallback,
delay = 200,
minDuration = 500,
}: {
children: React.ReactNode;
fallback: React.ReactNode;
delay?: number;
minDuration?: number;
}) {
const [showFallback, setShowFallback] = useState(false);
const [startTime, setStartTime] = useState<number | null>(
null
);
useEffect(() => {
const timer = setTimeout(() => {
setShowFallback(true);
setStartTime(Date.now());
}, delay);
return () => clearTimeout(timer);
}, [delay]);
const handleResolved = useCallback(() => {
if (startTime) {
const elapsed = Date.now() - startTime;
if (elapsed < minDuration) {
setTimeout(() => {
setShowFallback(false);
}, minDuration - elapsed);
return;
}
}
setShowFallback(false);
}, [startTime, minDuration]);
return (
<Suspense fallback={showFallback ? fallback : null}>
<SuspenseResolver onResolved={handleResolved}>
{children}
</SuspenseResolver>
</Suspense>
);
}
// アニメーション付きスケルトンUI
function EnhancedSkeleton({
lines = 3,
avatar = false,
}: {
lines?: number;
avatar?: boolean;
}) {
return (
<div className='skeleton-container'>
{avatar && (
<div className='skeleton-avatar animate-pulse'></div>
)}
<div className='skeleton-content'>
{Array.from({ length: lines }, (_, i) => (
<div
key={i}
className={`skeleton-line animate-pulse`}
style={{
width: i === lines - 1 ? '70%' : '100%',
animationDelay: `${i * 0.1}s`,
}}
></div>
))}
</div>
</div>
);
}
// 複数レベルのSuspense境界
function MultiLevelContent() {
return (
<div className='app-layout'>
<ProgressiveSuspense
fallback={<HeaderSkeleton />}
delay={100}
>
<Header />
</ProgressiveSuspense>
<main className='main-content'>
<ProgressiveSuspense
fallback={
<EnhancedSkeleton lines={5} avatar={true} />
}
delay={200}
>
<UserProfile />
</ProgressiveSuspense>
<div className='content-grid'>
<ProgressiveSuspense
fallback={<PostsSkeleton />}
delay={300}
>
<PostsList />
</ProgressiveSuspense>
<ProgressiveSuspense
fallback={<SidebarSkeleton />}
delay={400}
>
<Sidebar />
</ProgressiveSuspense>
</div>
</main>
</div>
);
}
段階的コンテンツ表示
ユーザーの体感速度を向上させるため、重要なコンテンツから順番に表示する段階的ローディングを実装しましょう。
typescript// 優先度付きデータ取得
const criticalDataAtom = atom(async () => {
// 最重要データ(ユーザー情報など)
const response = await fetch('/api/critical-data');
return response.json();
});
const secondaryDataAtom = atom(async () => {
// 二次的データ(統計情報など)
await new Promise((resolve) => setTimeout(resolve, 1000)); // 意図的な遅延
const response = await fetch('/api/secondary-data');
return response.json();
});
const tertiaryDataAtom = atom(async () => {
// 三次的データ(おすすめコンテンツなど)
await new Promise((resolve) => setTimeout(resolve, 2000)); // 意図的な遅延
const response = await fetch('/api/tertiary-data');
return response.json();
});
// 段階的表示コンポーネント
function StaggeredContent() {
return (
<div className='staggered-layout'>
{/* 第1段階:最重要コンテンツ */}
<Suspense fallback={<CriticalContentSkeleton />}>
<CriticalContent />
</Suspense>
{/* 第2段階:二次的コンテンツ */}
<Suspense fallback={<SecondaryContentPlaceholder />}>
<SecondaryContent />
</Suspense>
{/* 第3段階:三次的コンテンツ */}
<Suspense fallback={<TertiaryContentPlaceholder />}>
<TertiaryContent />
</Suspense>
</div>
);
}
function CriticalContent() {
const [criticalData] = useAtom(criticalDataAtom);
return (
<section className='critical-section fade-in'>
<h1>{criticalData.title}</h1>
<p>{criticalData.description}</p>
</section>
);
}
function SecondaryContent() {
const [secondaryData] = useAtom(secondaryDataAtom);
return (
<section className='secondary-section slide-in-left'>
<h2>統計情報</h2>
<div className='stats-grid'>
{secondaryData.stats.map((stat, index) => (
<div key={index} className='stat-card'>
<span className='stat-value'>{stat.value}</span>
<span className='stat-label'>{stat.label}</span>
</div>
))}
</div>
</section>
);
}
function TertiaryContent() {
const [tertiaryData] = useAtom(tertiaryDataAtom);
return (
<section className='tertiary-section slide-in-right'>
<h3>おすすめコンテンツ</h3>
<div className='recommendations'>
{tertiaryData.recommendations.map((item, index) => (
<div key={index} className='recommendation-card'>
<img src={item.image} alt={item.title} />
<h4>{item.title}</h4>
<p>{item.description}</p>
</div>
))}
</div>
</section>
);
}
Streaming UI の実現
React 18 の Streaming SSR と Jotai を組み合わせることで、サーバーサイドから段階的にコンテンツをストリーミングできます。
typescriptimport { Suspense } from 'react';
import { atom } from 'jotai';
// サーバーサイドレンダリング対応のatom
const serverDataAtom = atom(async () => {
// サーバーサイドでは即座に初期データを返す
if (typeof window === 'undefined') {
return {
message: 'サーバーサイドの初期データ',
timestamp: Date.now(),
};
}
// クライアントサイドでは追加データを取得
const response = await fetch('/api/enhanced-data');
return response.json();
});
// ストリーミング対応のレイアウト
function StreamingLayout() {
return (
<html>
<head>
<title>Streaming UI Demo</title>
</head>
<body>
<div id='root'>
{/* ヘッダーは即座に表示 */}
<header className='app-header'>
<h1>アプリケーション名</h1>
</header>
{/* メインコンテンツはStreaming */}
<main>
<Suspense fallback={<MainContentSkeleton />}>
<MainContent />
</Suspense>
{/* サイドバーも並行してStreaming */}
<aside>
<Suspense fallback={<SidebarSkeleton />}>
<SidebarContent />
</Suspense>
</aside>
</main>
{/* フッターは最後に表示 */}
<footer>
<Suspense fallback={<FooterSkeleton />}>
<FooterContent />
</Suspense>
</footer>
</div>
</body>
</html>
);
}
function MainContent() {
const [data] = useAtom(serverDataAtom);
return (
<section className='main-content'>
<h2>メインコンテンツ</h2>
<p>{data.message}</p>
<small>
生成時刻:{' '}
{new Date(data.timestamp).toLocaleString()}
</small>
</section>
);
}
ユーザーの体感速度向上テクニック
体感速度の向上は、技術的なパフォーマンスと同じくらい重要です。React 18 の useTransition
と Jotai を組み合わせて、滑らかなユーザーインタラクションを実現しましょう。
typescriptimport { useTransition, startTransition } from 'react';
import { atom } from 'jotai';
// 検索機能のatom
const searchQueryAtom = atom('');
const searchResultsAtom = atom(async (get) => {
const query = get(searchQueryAtom);
if (!query.trim()) {
return [];
}
// デバウンス効果
await new Promise((resolve) => setTimeout(resolve, 300));
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
});
// 楽観的UI更新のatom
const optimisticResultsAtom = atom<SearchResult[]>([]);
function SmartSearch() {
const [query, setQuery] = useAtom(searchQueryAtom);
const [results] = useAtom(searchResultsAtom);
const [optimisticResults, setOptimisticResults] = useAtom(
optimisticResultsAtom
);
const [isPending, startTransition] = useTransition();
const handleSearch = (newQuery: string) => {
// 即座にUIを更新(楽観的更新)
setOptimisticResults(
generateOptimisticResults(newQuery)
);
// 実際の検索は低優先度で実行
startTransition(() => {
setQuery(newQuery);
});
};
const displayResults = isPending
? optimisticResults
: results;
return (
<div className='smart-search'>
<input
type='text'
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder='検索キーワードを入力...'
className='search-input'
/>
<div
className={`search-results ${
isPending ? 'pending' : ''
}`}
>
{displayResults.map((result, index) => (
<div
key={result.id || index}
className='result-item'
>
<h3>{result.title}</h3>
<p>{result.description}</p>
</div>
))}
</div>
{isPending && (
<div className='search-indicator'>
<span>検索中...</span>
</div>
)}
</div>
);
}
// 楽観的結果の生成
function generateOptimisticResults(
query: string
): SearchResult[] {
if (!query.trim()) return [];
return [
{
id: 'temp-1',
title: `"${query}" に関する結果`,
description: '検索中です...',
},
{
id: 'temp-2',
title: '関連する提案',
description: '候補を取得中です...',
},
];
}
エラーハンドリングの完全対応
try-catch vs Error Boundary
Jotai の非同期処理でのエラーハンドリングには、複数のアプローチがあります。適切な使い分けが重要です。
typescript// Error Boundary用のコンポーネント
class AsyncErrorBoundary extends React.Component<
{
children: React.ReactNode;
fallback: React.ComponentType<{
error: Error;
retry: () => void;
}>;
},
{ hasError: boolean; error: Error | null }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(
error: Error,
errorInfo: React.ErrorInfo
) {
console.error(
'Async Error Boundary caught an error:',
error,
errorInfo
);
}
retry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
const FallbackComponent = this.props.fallback;
return (
<FallbackComponent
error={this.state.error!}
retry={this.retry}
/>
);
}
return this.props.children;
}
}
// atom内でのエラーハンドリング
const resilientDataAtom = atom(async (get) => {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(
`API Error: ${response.status} ${response.statusText}`
);
}
return await response.json();
} catch (error) {
// ログ出力
console.error('データ取得エラー:', error);
// フォールバックデータを返す
return {
id: null,
message: 'データの取得に失敗しました',
isError: true,
error: error.message,
};
}
});
// エラー状態を管理するatom
const errorStateAtom = atom<Error | null>(null);
const safeDataAtom = atom(async (get) => {
try {
set(errorStateAtom, null);
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
return await response.json();
} catch (error) {
set(errorStateAtom, error as Error);
throw error; // Error Boundaryに委譲
}
});
// 使用例
function DataDisplay() {
const [error] = useAtom(errorStateAtom);
if (error) {
return <ErrorMessage error={error} />;
}
return (
<AsyncErrorBoundary
fallback={({ error, retry }) => (
<ErrorFallback error={error} onRetry={retry} />
)}
>
<Suspense fallback={<LoadingSpinner />}>
<DataContent />
</Suspense>
</AsyncErrorBoundary>
);
}
function ErrorFallback({
error,
onRetry,
}: {
error: Error;
onRetry: () => void;
}) {
return (
<div className='error-fallback'>
<h3>エラーが発生しました</h3>
<p>{error.message}</p>
<button onClick={onRetry} className='retry-button'>
再試行
</button>
</div>
);
}
部分的エラー回復の実装
アプリケーション全体を停止させるのではなく、エラーが発生した部分のみを適切に処理する仕組みを構築しましょう。
typescript// 部分的エラー処理のためのユーティリティatom
function createFallbackAtom<T>(
primaryAtom: Atom<Promise<T>>,
fallbackValue: T,
retryable: boolean = true
) {
const errorAtom = atom<Error | null>(null);
const retryCountAtom = atom(0);
const resultAtom = atom(async (get) => {
const retryCount = get(retryCountAtom);
try {
const result = await get(primaryAtom);
set(errorAtom, null);
return result;
} catch (error) {
set(errorAtom, error as Error);
// リトライ制限チェック
if (retryable && retryCount < 3) {
console.warn(
`データ取得失敗 (${retryCount + 1}/3): ${
error.message
}`
);
return fallbackValue;
}
throw error;
}
});
const retryAtom = atom(null, (get, set) => {
const currentCount = get(retryCountAtom);
set(retryCountAtom, currentCount + 1);
set(errorAtom, null);
});
return { resultAtom, errorAtom, retryAtom };
}
// ダッシュボード用の部分的エラー処理
const userStatsAtom = atom(async () => {
const response = await fetch('/api/user/stats');
if (!response.ok)
throw new Error('統計データの取得に失敗');
return response.json();
});
const recentActivitiesAtom = atom(async () => {
const response = await fetch('/api/user/activities');
if (!response.ok)
throw new Error('アクティビティデータの取得に失敗');
return response.json();
});
// フォールバック付きatom
const {
resultAtom: safeUserStatsAtom,
errorAtom: statsErrorAtom,
retryAtom: retryStatsAtom,
} = createFallbackAtom(userStatsAtom, {
totalPosts: 0,
totalLikes: 0,
followers: 0,
});
const {
resultAtom: safeActivitiesAtom,
errorAtom: activitiesErrorAtom,
retryAtom: retryActivitiesAtom,
} = createFallbackAtom(recentActivitiesAtom, []);
function PartialErrorDashboard() {
return (
<div className='dashboard'>
<h1>ユーザーダッシュボード</h1>
{/* 統計セクション */}
<section className='stats-section'>
<ErrorBoundary fallback={<StatsErrorFallback />}>
<Suspense fallback={<StatsSkeleton />}>
<StatsDisplay />
</Suspense>
</ErrorBoundary>
</section>
{/* アクティビティセクション */}
<section className='activities-section'>
<ErrorBoundary
fallback={<ActivitiesErrorFallback />}
>
<Suspense fallback={<ActivitiesSkeleton />}>
<ActivitiesDisplay />
</Suspense>
</ErrorBoundary>
</section>
</div>
);
}
function StatsDisplay() {
const [stats] = useAtom(safeUserStatsAtom);
const [error] = useAtom(statsErrorAtom);
const [, retryStats] = useAtom(retryStatsAtom);
return (
<div className='stats-display'>
<h2>統計情報</h2>
{error && (
<div className='error-notice'>
<p>最新の統計データを取得できませんでした</p>
<button onClick={retryStats}>再試行</button>
</div>
)}
<div className='stats-grid'>
<div className='stat-item'>
<span className='stat-value'>
{stats.totalPosts}
</span>
<span className='stat-label'>投稿数</span>
</div>
<div className='stat-item'>
<span className='stat-value'>
{stats.totalLikes}
</span>
<span className='stat-label'>いいね数</span>
</div>
<div className='stat-item'>
<span className='stat-value'>
{stats.followers}
</span>
<span className='stat-label'>フォロワー数</span>
</div>
</div>
</div>
);
}
Jotai で構築する堅牢なアーキテクチャ
大規模アプリケーションでの atom 設計
大規模なアプリケーションでは、atom の組織化と管理が成功の鍵となります。適切な設計パターンを採用することで、保守性と拡張性を確保できます。
typescript// ドメイン別のatom組織化
// domains/user/atoms.ts
export const userDomainAtoms = {
// 基本データ
currentUserIdAtom: atom<string | null>(null),
userProfileAtom: atom<UserProfile | null>(null),
userPreferencesAtom: atom<UserPreferences>(
defaultPreferences
),
// 派生データ
userDisplayNameAtom: atom((get) => {
const profile = get(userDomainAtoms.userProfileAtom);
return profile
? `${profile.firstName} ${profile.lastName}`
: 'ゲスト';
}),
isAuthenticatedAtom: atom((get) => {
return get(userDomainAtoms.currentUserIdAtom) !== null;
}),
// アクション
loginAtom: atom(
null,
async (get, set, credentials: LoginCredentials) => {
const response = await authAPI.login(credentials);
set(
userDomainAtoms.currentUserIdAtom,
response.user.id
);
set(
userDomainAtoms.userProfileAtom,
response.user.profile
);
}
),
logoutAtom: atom(null, async (get, set) => {
await authAPI.logout();
set(userDomainAtoms.currentUserIdAtom, null);
set(userDomainAtoms.userProfileAtom, null);
set(
userDomainAtoms.userPreferencesAtom,
defaultPreferences
);
}),
};
// domains/posts/atoms.ts
export const postsDomainAtoms = {
allPostsAtom: atom<Post[]>([]),
selectedPostIdAtom: atom<string | null>(null),
postFiltersAtom: atom<PostFilters>({
category: 'all',
sortBy: 'date',
order: 'desc',
}),
filteredPostsAtom: atom((get) => {
const posts = get(postsDomainAtoms.allPostsAtom);
const filters = get(postsDomainAtoms.postFiltersAtom);
return posts
.filter(
(post) =>
filters.category === 'all' ||
post.category === filters.category
)
.sort((a, b) => {
const multiplier = filters.order === 'asc' ? 1 : -1;
return (
(a[filters.sortBy] > b[filters.sortBy] ? 1 : -1) *
multiplier
);
});
}),
selectedPostAtom: atom((get) => {
const posts = get(postsDomainAtoms.allPostsAtom);
const selectedId = get(
postsDomainAtoms.selectedPostIdAtom
);
return (
posts.find((post) => post.id === selectedId) || null
);
}),
createPostAtom: atom(
null,
async (get, set, postData: CreatePostData) => {
const newPost = await postsAPI.create(postData);
const currentPosts = get(
postsDomainAtoms.allPostsAtom
);
set(postsDomainAtoms.allPostsAtom, [
newPost,
...currentPosts,
]);
return newPost;
}
),
};
// グローバル統合レイヤー
// store/index.ts
export const globalStore = {
user: userDomainAtoms,
posts: postsDomainAtoms,
// クロスドメインの派生atom
userPostsAtom: atom((get) => {
const currentUserId = get(
userDomainAtoms.currentUserIdAtom
);
const allPosts = get(postsDomainAtoms.allPostsAtom);
if (!currentUserId) return [];
return allPosts.filter(
(post) => post.authorId === currentUserId
);
}),
dashboardDataAtom: atom(async (get) => {
const [userProfile, userPosts, allPosts] =
await Promise.all([
get(userDomainAtoms.userProfileAtom),
get(globalStore.userPostsAtom),
get(postsDomainAtoms.allPostsAtom),
]);
return {
profile: userProfile,
userPostsCount: userPosts.length,
totalPostsCount: allPosts.length,
lastActivity: userPosts[0]?.createdAt || null,
};
}),
};
パフォーマンス監視とプロファイリング
本番環境でのパフォーマンス監視は、アプリケーションの品質維持に不可欠です。Jotai の atom 使用状況を監視する仕組みを構築しましょう。
typescript// パフォーマンス監視用のユーティリティ
class JotaiPerformanceMonitor {
private atomUsageMap = new Map<string, AtomUsageStats>();
private renderCounts = new Map<string, number>();
// atom使用統計の追跡
trackAtomUsage(
atomName: string,
operation: 'read' | 'write',
duration: number
) {
if (!this.atomUsageMap.has(atomName)) {
this.atomUsageMap.set(atomName, {
reads: 0,
writes: 0,
totalReadTime: 0,
totalWriteTime: 0,
avgReadTime: 0,
avgWriteTime: 0,
});
}
const stats = this.atomUsageMap.get(atomName)!;
if (operation === 'read') {
stats.reads++;
stats.totalReadTime += duration;
stats.avgReadTime = stats.totalReadTime / stats.reads;
} else {
stats.writes++;
stats.totalWriteTime += duration;
stats.avgWriteTime =
stats.totalWriteTime / stats.writes;
}
}
// レンダリング回数の追跡
trackRender(componentName: string) {
const current =
this.renderCounts.get(componentName) || 0;
this.renderCounts.set(componentName, current + 1);
}
// 統計レポートの生成
generateReport(): PerformanceReport {
return {
atomStats: Object.fromEntries(this.atomUsageMap),
renderStats: Object.fromEntries(this.renderCounts),
timestamp: new Date().toISOString(),
recommendations: this.generateRecommendations(),
};
}
private generateRecommendations(): string[] {
const recommendations: string[] = [];
// 高頻度で読み取られているatomの識別
for (const [atomName, stats] of this.atomUsageMap) {
if (stats.reads > 100 && stats.avgReadTime > 10) {
recommendations.push(
`${atomName}: 読み取り頻度が高く、平均処理時間が長いです。キャッシュ機能の追加を検討してください。`
);
}
}
// 高頻度でレンダリングされるコンポーネントの識別
for (const [componentName, count] of this
.renderCounts) {
if (count > 50) {
recommendations.push(
`${componentName}: レンダリング回数が多いです。React.memoの使用を検討してください。`
);
}
}
return recommendations;
}
}
// 監視機能付きのカスタムフック
function useMonitoredAtom<Value>(
atom: Atom<Value>,
atomName: string
) {
const monitor = useRef(
new JotaiPerformanceMonitor()
).current;
const result = useAtom(
useMemo(() => {
return atom((get) => {
const start = performance.now();
const value = get(atom);
const duration = performance.now() - start;
monitor.trackAtomUsage(atomName, 'read', duration);
return value;
});
}, [atom, atomName, monitor])
);
useEffect(() => {
monitor.trackRender(`useMonitoredAtom(${atomName})`);
});
return result;
}
// 使用例
function MonitoredUserProfile() {
const [userData] = useMonitoredAtom(
userDataAtom,
'userData'
);
const [posts] = useMonitoredAtom(
userPostsAtom,
'userPosts'
);
return (
<div className='user-profile'>
<h2>{userData.name}</h2>
<p>投稿数: {posts.length}</p>
</div>
);
}
// パフォーマンスダッシュボード
function PerformanceDashboard() {
const [report, setReport] =
useState<PerformanceReport | null>(null);
const monitor = useRef(
new JotaiPerformanceMonitor()
).current;
useEffect(() => {
const interval = setInterval(() => {
setReport(monitor.generateReport());
}, 5000);
return () => clearInterval(interval);
}, [monitor]);
if (!report)
return <div>パフォーマンス監視開始中...</div>;
return (
<div className='performance-dashboard'>
<h2>Jotai パフォーマンス監視</h2>
<section className='atom-stats'>
<h3>Atom 使用統計</h3>
<table>
<thead>
<tr>
<th>Atom名</th>
<th>読み取り回数</th>
<th>平均読み取り時間(ms)</th>
<th>書き込み回数</th>
<th>平均書き込み時間(ms)</th>
</tr>
</thead>
<tbody>
{Object.entries(report.atomStats).map(
([name, stats]) => (
<tr key={name}>
<td>{name}</td>
<td>{stats.reads}</td>
<td>{stats.avgReadTime.toFixed(2)}</td>
<td>{stats.writes}</td>
<td>{stats.avgWriteTime.toFixed(2)}</td>
</tr>
)
)}
</tbody>
</table>
</section>
<section className='recommendations'>
<h3>最適化の提案</h3>
<ul>
{report.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</section>
</div>
);
}
TypeScript 完全対応の実装パターン
TypeScript との組み合わせにより、Jotai アプリケーションの型安全性を最大化できます。厳密な型定義により、開発時のエラーを削減し、リファクタリングを安全に実行できます。
typescript// 厳密な型定義のベースパターン
interface User {
id: string;
name: string;
email: string;
avatar?: string;
createdAt: Date;
}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
tags: string[];
publishedAt: Date;
updatedAt: Date;
}
// 型安全なAtom定義
const typedUserAtom = atom<User | null>(null);
const typedPostsAtom = atom<Post[]>([]);
// 型推論を活用した派生Atom
const userPostsAtom = atom((get) => {
const user = get(typedUserAtom);
const posts = get(typedPostsAtom);
if (!user) return [];
// TypeScriptが型を自動推論
return posts.filter((post) => post.authorId === user.id);
});
// 型安全なAsyncAtom
const fetchUserAtom = atom(
async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(
`ユーザー取得エラー: ${response.status}`
);
}
const userData = await response.json();
// レスポンスの型検証
return {
id: userData.id,
name: userData.name,
email: userData.email,
avatar: userData.avatar || undefined,
createdAt: new Date(userData.created_at),
};
}
);
// ジェネリクスを活用した再利用可能なパターン
function createTypedAsyncAtom<T, P = void>(
fetcher: (params: P) => Promise<T>,
defaultValue?: T
) {
const paramsAtom = atom<P | null>(null);
const dataAtom = atom<T | undefined>(defaultValue);
const loadingAtom = atom(false);
const errorAtom = atom<Error | null>(null);
const fetchAtom = atom(
null,
async (get, set, params: P) => {
set(loadingAtom, true);
set(errorAtom, null);
try {
const result = await fetcher(params);
set(dataAtom, result);
set(paramsAtom, params);
return result;
} catch (error) {
set(errorAtom, error as Error);
throw error;
} finally {
set(loadingAtom, false);
}
}
);
const stateAtom = atom((get) => ({
data: get(dataAtom),
loading: get(loadingAtom),
error: get(errorAtom),
params: get(paramsAtom),
}));
return { fetchAtom, stateAtom };
}
// 使用例(完全な型安全性)
const {
fetchAtom: fetchUserDataAtom,
stateAtom: userDataStateAtom,
} = createTypedAsyncAtom<User, string>(
async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
function TypeSafeUserProfile({
userId,
}: {
userId: string;
}) {
const [, fetchUser] = useAtom(fetchUserDataAtom);
const [state] = useAtom(userDataStateAtom);
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
if (state.loading)
return <div>ユーザー情報を読み込み中...</div>;
if (state.error)
return <div>エラー: {state.error.message}</div>;
if (!state.data)
return <div>ユーザーが見つかりません</div>;
// state.dataは完全にUser型として推論される
return (
<div className='user-profile'>
<h2>{state.data.name}</h2>
<p>{state.data.email}</p>
{state.data.avatar && (
<img
src={state.data.avatar}
alt={`${state.data.name}のアバター`}
/>
)}
</div>
);
}
開発生産性を向上させる Jotai テクニック
カスタムフック vs 直接 atom 使用
適切な抽象化レベルの選択は、コードの可読性と保守性に大きく影響します。状況に応じて最適なアプローチを選択しましょう。
typescript// 直接atom使用パターン(シンプルな場合に最適)
function SimpleCounter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>
+1
</button>
</div>
);
}
// カスタムフックパターン(複雑なロジックの場合)
function useUserManagement() {
const [currentUser, setCurrentUser] =
useAtom(currentUserAtom);
const [isLoading, setIsLoading] =
useAtom(userLoadingAtom);
const [error, setError] = useAtom(userErrorAtom);
const login = useCallback(
async (credentials: LoginCredentials) => {
setIsLoading(true);
setError(null);
try {
const response = await authAPI.login(credentials);
setCurrentUser(response.user);
return response.user;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsLoading(false);
}
},
[setCurrentUser, setIsLoading, setError]
);
const logout = useCallback(async () => {
setIsLoading(true);
try {
await authAPI.logout();
setCurrentUser(null);
} catch (err) {
setError(err as Error);
} finally {
setIsLoading(false);
}
}, [setCurrentUser, setIsLoading, setError]);
const updateProfile = useCallback(
async (updates: Partial<User>) => {
if (!currentUser)
throw new Error('ユーザーがログインしていません');
setIsLoading(true);
try {
const updatedUser = await userAPI.updateProfile(
currentUser.id,
updates
);
setCurrentUser(updatedUser);
return updatedUser;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setIsLoading(false);
}
},
[currentUser, setCurrentUser, setIsLoading, setError]
);
return {
currentUser,
isLoading,
error,
login,
logout,
updateProfile,
isAuthenticated: currentUser !== null,
};
}
テスト戦略とモッキング手法
堅牢なアプリケーションには、包括的なテスト戦略が不可欠です。Jotai の atom に対する効果的なテスト手法をマスターしましょう。
typescript// テスト用のユーティリティ関数
import { getDefaultStore } from 'jotai';
import { renderHook, act } from '@testing-library/react';
// atomのユニットテスト
describe('userAtoms', () => {
let store: ReturnType<typeof getDefaultStore>;
beforeEach(() => {
store = getDefaultStore();
});
test('ユーザー情報の更新', () => {
const testUser: User = {
id: '1',
name: '田中太郎',
email: 'tanaka@example.com',
createdAt: new Date(),
};
// atomに値を設定
store.set(currentUserAtom, testUser);
// 値が正しく設定されているか確認
expect(store.get(currentUserAtom)).toEqual(testUser);
// 派生atomも正しく計算されているか確認
expect(store.get(userDisplayNameAtom)).toBe('田中太郎');
expect(store.get(isAuthenticatedAtom)).toBe(true);
});
test('非同期atomのテスト', async () => {
// APIをモック
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
id: '1',
name: 'テストユーザー',
email: 'test@example.com',
created_at: '2023-01-01T00:00:00Z',
}),
});
global.fetch = mockFetch;
// 非同期atomの実行
const result = await store.get(fetchUserAtom('1'));
// 結果の検証
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
expect(result.name).toBe('テストユーザー');
expect(result.createdAt).toBeInstanceOf(Date);
});
});
DevTools 連携とデバッグ効率化
開発効率を最大化するため、Jotai DevTools との連携とデバッグツールを活用しましょう。
typescript// デバッグ用のatomラッパー
function debugAtom<Value>(
baseAtom: Atom<Value>,
label: string
): Atom<Value> {
if (process.env.NODE_ENV === 'production') {
return baseAtom;
}
return atom(
(get) => {
const value = get(baseAtom);
console.log(`[Atom Debug] ${label}:`, value);
return value;
},
(get, set, update) => {
console.log(
`[Atom Debug] ${label} 更新前:`,
get(baseAtom)
);
set(baseAtom, update);
console.log(
`[Atom Debug] ${label} 更新後:`,
get(baseAtom)
);
}
);
}
// デバッグ対応atom
const debugUserAtom = debugAtom(
currentUserAtom,
'CurrentUser'
);
const debugPostsAtom = debugAtom(postsAtom, 'Posts');
移行とアップグレード戦略
既存アプリへの Jotai 導入手順
既存のアプリケーションに Jotai を段階的に導入するための実践的なアプローチを説明します。
typescript// Step 1: 部分的導入の開始
// 最初は単一の機能領域から開始
function NewFeatureWithJotai() {
const [featureState, setFeatureState] =
useAtom(newFeatureAtom);
return (
<div className='new-feature'>
<h3>新機能(Jotai使用)</h3>
<button
onClick={() => setFeatureState((prev) => !prev)}
>
状態切り替え: {featureState ? 'ON' : 'OFF'}
</button>
</div>
);
}
// Step 2: 既存状態との橋渡し
function LegacyBridge() {
const legacyState = useContext(LegacyContext);
const [jotaiState, setJotaiState] = useAtom(bridgeAtom);
// 既存状態をJotai atomに同期
useEffect(() => {
setJotaiState({
userId: legacyState.user?.id,
preferences: legacyState.preferences,
});
}, [legacyState, setJotaiState]);
return <NewFeatureWithJotai />;
}
Redux/Zustand からの段階的移行
既存の状態管理ライブラリからの移行戦略を具体的に示します。
typescript// Redux からの移行例
// 1. Redux状態をJotai atomに変換
interface ReduxState {
user: User | null;
posts: Post[];
ui: {
theme: 'light' | 'dark';
sidebarOpen: boolean;
};
}
// 対応するJotai atoms
const userAtom = atom<User | null>(null);
const postsAtom = atom<Post[]>([]);
const themeAtom = atom<'light' | 'dark'>('light');
const sidebarOpenAtom = atom(false);
// 2. Redux Reducerロジックの移行
const addPostAtom = atom(
null,
(get, set, newPost: Post) => {
const currentPosts = get(postsAtom);
set(postsAtom, [...currentPosts, newPost]);
}
);
const updatePostAtom = atom(
null,
(get, set, updatedPost: Post) => {
const currentPosts = get(postsAtom);
set(
postsAtom,
currentPosts.map((post) =>
post.id === updatedPost.id ? updatedPost : post
)
);
}
);
// 3. Jotai Derived Atom
const userPostsAtom = atom((get) => {
const posts = get(postsAtom);
const user = get(userAtom);
return user
? posts.filter((post) => post.authorId === user.id)
: [];
});
まとめ
本記事では、React 18 と Jotai の連携による現代的な状態管理について包括的に解説してまいりました。この組み合わせは、単なる技術的な改善を超えて、開発体験そのものを革新する力を持っています。
Suspense との完璧な統合により、これまで煩雑だった非同期処理が驚くほどシンプルになりました。ローディング状態やエラーハンドリングを意識することなく、まるで同期処理のように美しいコードが書けるようになったのです。
React 18 の Concurrent Features と Jotai の細粒度更新が組み合わさることで、ユーザーの体感速度が大幅に向上します。useTransition
による楽観的更新、段階的なコンテンツ表示、そして Streaming UI の実現により、従来では困難だった滑らかなユーザーインタラクションが可能になります。
堅牢なアーキテクチャ設計では、大規模アプリケーションでも保守性を維持できる atom 組織化パターンを学びました。ドメイン別の分離、型安全性の確保、そしてパフォーマンス監視により、長期的な開発効率を保証できます。
開発生産性の向上においては、適切な抽象化レベルの選択、包括的なテスト戦略、そして効果的なデバッグ手法により、開発フローが劇的に改善されます。特に TypeScript との組み合わせにより、開発時のエラーを大幅に削減できるでしょう。
移行戦略についても、既存アプリケーションから段階的に Jotai を導入する実践的な手法を提供しました。リスクを最小化しながら、新しい技術の恩恵を享受できる道筋が明確になったはずです。
React 18 × Jotai の組み合わせは、これからの Web 開発における標準的なアプローチとなるでしょう。美しく、高性能で、保守性に優れたアプリケーションを構築するために、この技術の習得は必須のスキルとなっています。
ぜひ本記事で学んだテクニックを実際のプロジェクトで活用し、新しい開発体験を体感してください。Jotai と React 18 の力により、あなたの開発スキルは確実に次のレベルへと進化するはずです。
関連リンク
- review
もう朝起きるのが辛くない!『スタンフォード式 最高の睡眠』西野精治著で学んだ、たった 90 分で人生が変わる睡眠革命
- review
もう「なんとなく」で決めない!『解像度を上げる』馬田隆明著で身につけた、曖昧思考を一瞬で明晰にする技術
- review
もう疲れ知らず!『最高の体調』鈴木祐著で手に入れた、一生モノの健康習慣術
- review
人生が激変!『苦しかったときの話をしようか』森岡毅著で発見した、本当に幸せなキャリアの築き方
- review
もう「何言ってるの?」とは言わせない!『バナナの魅力を 100 文字で伝えてください』柿内尚文著 で今日からあなたも伝え方の達人!
- review
もう時間に追われない!『エッセンシャル思考』グレッグ・マキューンで本当に重要なことを見抜く!