Reactのasync/awaitが使えない?非同期処理の正しい書き方
Reactコンポーネントでasync/awaitを正しく使う方法を解説。useEffectでの非同期処理、カスタムフック、エラーハンドリングまで実践的に説明します。
Reactで非同期処理を書いていて、こんな疑問を持ったことはありませんか?
「コンポーネントでasync/awaitが使えないの?」って思いませんか? 「useEffectでasync関数を使おうとするとエラーが出る...」って困ったことありませんか? 「データフェッチの正しい書き方がわからない」と悩んでいませんか?
そんな悩みを抱えているあなたに、この記事はぴったりです。
React開発において、非同期処理は避けて通れない重要な概念です。 APIからのデータ取得、ファイルのアップロード、タイマー処理など、様々な場面で必要になります。
しかし、Reactには独特のルールがあります。 通常のJavaScriptとは異なる書き方が求められることがあるんです。
この記事では、Reactにおける非同期処理の正しい書き方を詳しく解説します。 async/awaitの基本から実践的なパターンまで、一緒に学んでいきましょう。
なぜReactでasync/awaitが「使えない」のか
まず、Reactにおけるasync/awaitの制限と、その理由を理解しましょう。
コンポーネント関数にasyncを付けられない理由
// ❌ これはエラーになるasync function BadComponent() { const data = await fetch('/api/data'); const result = await data.json(); return <div>{result.message}</div>;}
// ❌ この書き方もエラーconst BadComponent2 = async () => { const data = await fetch('/api/data'); const result = await data.json(); return <div>{result.message}</div>;};
なぜこれがダメなのでしょうか? 実は、とても重要な理由があります。
エラーの理由
// Reactコンポーネントの期待される戻り値// 1. JSX要素// 2. null// 3. undefined// 4. 文字列// 5. 数値// 6. 配列
// async関数の戻り値async function example() { return <div>Hello</div>;}
// ↓ 実際の戻り値は Promise<JSX.Element>const result = example(); // Promise オブジェクト
// Reactは Promise を直接レンダリングできないconsole.log(result); // Promise { <div>Hello</div> }
async関数は、必ずPromiseを返します。 でも、ReactはPromiseをそのまま画面に表示できないんです。
useEffectでのasync制限
// ❌ useEffectでasyncを直接使うのは禁止function BadEffectComponent() { const [data, setData] = useState(null); // これはエラーまたは警告が出る useEffect(async () => { const response = await fetch('/api/data'); const result = await response.json(); setData(result); }, []); return <div>{data?.message}</div>;}
これもエラーになってしまいます。 なぜでしょうか?
なぜuseEffectにasyncが使えないのか
// useEffectの期待される戻り値useEffect(() => { // クリーンアップ関数を返すか、何も返さない return () => { // クリーンアップ処理 };}, []);
// async関数を使った場合useEffect(async () => { // async関数は必ずPromiseを返す await someAsyncOperation(); // ↓ この関数の戻り値は Promise<undefined>}, []);
// Reactはクリーンアップ関数としてPromiseを受け取れない
useEffectは、クリーンアップ関数を期待しています。 でも、async関数はPromiseを返すので、うまく動かないんです。
正しい非同期処理のパターン
大丈夫です!正しい方法があります。
// ✅ 正しい書き方1: useEffect内でasync関数を定義function CorrectComponent1() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // async関数を内部で定義 const fetchData = async () => { try { setLoading(true); const response = await fetch('/api/data'); const result = await response.json(); setData(result); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchData(); // async関数を呼び出し }, []); if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error}</div>; return <div>{data?.message}</div>;}
// ✅ 正しい書き方2: 即座実行関数(IIFE)を使用function CorrectComponent2() { const [data, setData] = useState(null); useEffect(() => { // 即座実行async関数 (async () => { try { const response = await fetch('/api/data'); const result = await response.json(); setData(result); } catch (error) { console.error('Fetch error:', error); } })(); }, []); return <div>{data?.message}</div>;}
この方法なら、useEffectの中でasync/awaitが使えます。 ポイントは、useEffect自体をasyncにするのではなく、内部でasync関数を定義して呼び出すことです。
Promiseベースの書き方との比較
従来の方法と比べてみましょう。
// Promise.thenを使った従来の書き方function PromiseComponent() { const [data, setData] = useState(null); useEffect(() => { fetch('/api/data') .then(response => response.json()) .then(result => setData(result)) .catch(error => console.error('Error:', error)); }, []); return <div>{data?.message}</div>;}
// async/awaitを使った現代的な書き方function AsyncAwaitComponent() { const [data, setData] = useState(null); useEffect(() => { const fetchData = async () => { try { const response = await fetch('/api/data'); const result = await response.json(); setData(result); } catch (error) { console.error('Error:', error); } }; fetchData(); }, []); return <div>{data?.message}</div>;}
async/await版の方が、読みやすいですよね。 エラーハンドリングも、try-catchで統一できて分かりやすいです。
これらの制限を理解することで、Reactで適切な非同期処理を書けるようになります。
useEffectでの正しい非同期処理
useEffectで非同期処理を行う正しい方法を、実践的なパターンとともに解説します。
基本的なデータフェッチパターン
まずは、標準的なデータフェッチの実装を見てみましょう。
// 標準的なデータフェッチの実装function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // アボートコントローラーでリクエストキャンセル対応 const abortController = new AbortController(); const fetchUser = async () => { try { setLoading(true); setError(null); const response = await fetch(`/api/users/${userId}`, { signal: abortController.signal // キャンセル可能にする }); if (!response.ok) { throw new Error(`User not found: ${response.status}`); } const userData = await response.json(); setUser(userData); } catch (err) { // アボートエラーは無視(コンポーネントのアンマウント時) if (err.name !== 'AbortError') { setError(err.message); } } finally { // アボートされていない場合のみローディングを終了 if (!abortController.signal.aborted) { setLoading(false); } } }; fetchUser(); // クリーンアップ関数でリクエストをキャンセル return () => { abortController.abort(); }; }, [userId]); // userIdが変更されたら再実行 if (loading) return <div className="loading">ユーザー情報を読み込み中...</div>; if (error) return <div className="error">エラー: {error}</div>; if (!user) return <div>ユーザーが見つかりません</div>; return ( <div className="user-profile"> <img src={user.avatar} alt={user.name} /> <h2>{user.name}</h2> <p>{user.email}</p> <p>最終ログイン: {new Date(user.lastLogin).toLocaleDateString()}</p> </div> );}
この実装のポイントを見ていきましょう。
AbortControllerを使って、コンポーネントがアンマウントされた時にリクエストをキャンセルしています。 これで、メモリリークを防げます。
loading、error、dataの3つの状態を適切に管理して、ユーザーに分かりやすいUIを提供しています。
依存配列にuserId
を指定して、userIdが変更された時に再度データを取得するようにしています。
複数の非同期処理を並行実行
複数のAPIを同時に呼び出したい場合の実装です。
function Dashboard() { const [userStats, setUserStats] = useState(null); const [recentPosts, setRecentPosts] = useState(null); const [notifications, setNotifications] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const abortController = new AbortController(); const fetchDashboardData = async () => { try { setLoading(true); setError(null); // 複数のAPIを並行して呼び出し const [statsResponse, postsResponse, notificationsResponse] = await Promise.all([ fetch('/api/stats', { signal: abortController.signal }), fetch('/api/posts/recent', { signal: abortController.signal }), fetch('/api/notifications', { signal: abortController.signal }) ]); // レスポンスステータスを確認 if (!statsResponse.ok) throw new Error('統計データの取得に失敗'); if (!postsResponse.ok) throw new Error('投稿データの取得に失敗'); if (!notificationsResponse.ok) throw new Error('通知データの取得に失敗'); // JSON変換も並行実行 const [stats, posts, notifications] = await Promise.all([ statsResponse.json(), postsResponse.json(), notificationsResponse.json() ]); setUserStats(stats); setRecentPosts(posts); setNotifications(notifications); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { if (!abortController.signal.aborted) { setLoading(false); } } }; fetchDashboardData(); return () => { abortController.abort(); }; }, []); if (loading) return <div>ダッシュボードを読み込み中...</div>; if (error) return <div>エラー: {error}</div>; return ( <div className="dashboard"> <section className="stats"> <h2>統計</h2> {userStats && ( <div> <p>総投稿数: {userStats.totalPosts}</p> <p>総いいね数: {userStats.totalLikes}</p> <p>フォロワー数: {userStats.followers}</p> </div> )} </section> <section className="recent-posts"> <h2>最近の投稿</h2> {recentPosts && recentPosts.map(post => ( <div key={post.id} className="post-item"> <h3>{post.title}</h3> <p>{post.excerpt}</p> </div> ))} </section> <section className="notifications"> <h2>通知 ({notifications?.length || 0})</h2> {notifications && notifications.map(notification => ( <div key={notification.id} className="notification-item"> <p>{notification.message}</p> <small>{notification.createdAt}</small> </div> ))} </section> </div> );}
Promise.allを使って、複数のAPIを並行実行しています。 これで、処理時間を大幅に短縮できます。
それぞれのレスポンスをチェックして、エラーハンドリングも適切に行っています。
エラーハンドリングの詳細実装
より堅牢なエラーハンドリングの例です。
function RobustDataFetcher({ endpoint }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [retryCount, setRetryCount] = useState(0); const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1秒 useEffect(() => { const abortController = new AbortController(); const fetchWithRetry = async (url, retries = 0) => { try { setLoading(true); setError(null); const response = await fetch(url, { signal: abortController.signal, timeout: 10000 // 10秒タイムアウト }); // ステータスコード別のエラーハンドリング if (!response.ok) { if (response.status >= 500 && retries < MAX_RETRIES) { // サーバーエラーの場合はリトライ console.warn(`Server error ${response.status}, retrying... (${retries + 1}/${MAX_RETRIES})`); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (retries + 1))); return fetchWithRetry(url, retries + 1); } // リトライ不可能なエラー const errorMessages = { 400: 'リクエストが不正です', 401: '認証が必要です', 403: 'アクセス権限がありません', 404: 'データが見つかりません', 429: 'リクエストが多すぎます。しばらく待ってから再試行してください', 500: 'サーバーエラーが発生しました', 502: 'サーバーに接続できません', 503: 'サービスが一時的に利用できません' }; throw new Error(errorMessages[response.status] || `HTTP Error: ${response.status}`); } const result = await response.json(); setData(result); setRetryCount(retries); } catch (err) { if (err.name === 'AbortError') { return; // アボートは正常終了 } if (err.name === 'TypeError' && retries < MAX_RETRIES) { // ネットワークエラーの場合もリトライ console.warn(`Network error, retrying... (${retries + 1}/${MAX_RETRIES})`); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (retries + 1))); return fetchWithRetry(url, retries + 1); } setError(err.message); setRetryCount(retries); } finally { if (!abortController.signal.aborted) { setLoading(false); } } }; if (endpoint) { fetchWithRetry(endpoint); } return () => { abortController.abort(); }; }, [endpoint]); const retry = () => { setRetryCount(0); setError(null); // endpointを変更してuseEffectを再実行させる }; return ( <div> {loading && ( <div className="loading"> データを読み込み中... {retryCount > 0 && <span> (再試行 {retryCount}回目)</span>} </div> )} {error && ( <div className="error"> <p>エラー: {error}</p> {retryCount < MAX_RETRIES && ( <button onClick={retry}>再試行</button> )} </div> )} {data && ( <div className="data"> <pre>{JSON.stringify(data, null, 2)}</pre> </div> )} </div> );}
この実装では、自動リトライ機能を追加しています。 サーバーエラーやネットワークエラーの場合、自動的に再試行します。
ステータスコード別のエラーメッセージで、ユーザーに分かりやすい説明を提供しています。
手動リトライボタンも用意して、ユーザーが再試行できるようにしています。
カスタムフックで非同期処理を管理
再利用可能で保守性の高い非同期処理を実現するために、カスタムフックを活用しましょう。
基本的なデータフェッチフック
まずは、汎用的なAPIフェッチフックから見てみましょう。
// useApi.js - 汎用的なAPIフェッチフックimport { useState, useEffect, useCallback } from 'react';
function useApi(url, options = {}) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { method = 'GET', headers = {}, body = null, dependencies = [], enabled = true, retries = 3, retryDelay = 1000 } = options; const fetchData = useCallback(async () => { if (!enabled || !url) { setLoading(false); return; } const abortController = new AbortController(); let retryCount = 0; const attemptFetch = async () => { try { setLoading(true); setError(null); const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : null, signal: abortController.signal }); if (!response.ok) { if (response.status >= 500 && retryCount < retries) { retryCount++; await new Promise(resolve => setTimeout(resolve, retryDelay * retryCount)); return attemptFetch(); } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); setData(result); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { if (!abortController.signal.aborted) { setLoading(false); } } }; await attemptFetch(); return () => { abortController.abort(); }; }, [url, method, body, enabled, retries, retryDelay, ...dependencies]); useEffect(() => { const cleanup = fetchData(); return cleanup; }, [fetchData]); const refetch = useCallback(() => { fetchData(); }, [fetchData]); return { data, loading, error, refetch };}
このカスタムフックを使うと、こんなふうに書けます。
// 使用例function UserProfile({ userId }) { const { data: user, loading, error, refetch } = useApi( `/api/users/${userId}`, { dependencies: [userId], enabled: !!userId } ); if (loading) return <div>読み込み中...</div>; if (error) return ( <div> エラー: {error} <button onClick={refetch}>再試行</button> </div> ); return ( <div> <h1>{user?.name}</h1> <p>{user?.email}</p> <button onClick={refetch}>更新</button> </div> );}
カスタムフックを使うことで、コンポーネントがとてもシンプルになりました。 データフェッチのロジックを再利用できるのも大きなメリットです。
特化したデータフェッチフック
特定の用途に特化したフックも作れます。
// useUsers.js - ユーザー情報専用フックfunction useUsers(filters = {}) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0, totalPages: 0 }); const fetchUsers = useCallback(async (page = 1, limit = 10) => { setLoading(true); setError(null); try { const params = new URLSearchParams({ page: page.toString(), limit: limit.toString(), ...filters }); const response = await fetch(`/api/users?${params}`); if (!response.ok) throw new Error('ユーザー取得に失敗しました'); const result = await response.json(); setUsers(result.users); setPagination({ page: result.page, limit: result.limit, total: result.total, totalPages: result.totalPages }); } catch (err) { setError(err.message); } finally { setLoading(false); } }, [filters]); const createUser = useCallback(async (userData) => { try { setLoading(true); const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userData) }); if (!response.ok) throw new Error('ユーザー作成に失敗しました'); const newUser = await response.json(); setUsers(prev => [newUser, ...prev]); return newUser; } catch (err) { setError(err.message); throw err; } finally { setLoading(false); } }, []); const updateUser = useCallback(async (userId, updates) => { try { setLoading(true); const response = await fetch(`/api/users/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); if (!response.ok) throw new Error('ユーザー更新に失敗しました'); const updatedUser = await response.json(); setUsers(prev => prev.map(user => user.id === userId ? updatedUser : user )); return updatedUser; } catch (err) { setError(err.message); throw err; } finally { setLoading(false); } }, []); const deleteUser = useCallback(async (userId) => { try { setLoading(true); const response = await fetch(`/api/users/${userId}`, { method: 'DELETE' }); if (!response.ok) throw new Error('ユーザー削除に失敗しました'); setUsers(prev => prev.filter(user => user.id !== userId)); } catch (err) { setError(err.message); throw err; } finally { setLoading(false); } }, []); useEffect(() => { fetchUsers(pagination.page, pagination.limit); }, [fetchUsers]); return { users, loading, error, pagination, fetchUsers, createUser, updateUser, deleteUser };}
このフックを使うと、CRUD操作が簡単に実装できます。
// 使用例function UserManagement() { const [filters, setFilters] = useState({ role: '', status: '' }); const { users, loading, error, pagination, createUser, updateUser, deleteUser } = useUsers(filters); const handleCreateUser = async (userData) => { try { await createUser(userData); alert('ユーザーが作成されました'); } catch (error) { alert('ユーザー作成に失敗しました'); } }; return ( <div> <h1>ユーザー管理</h1> {loading && <div>読み込み中...</div>} {error && <div>エラー: {error}</div>} <div className="user-list"> {users.map(user => ( <div key={user.id} className="user-item"> <span>{user.name}</span> <button onClick={() => updateUser(user.id, { status: 'inactive' })}> 無効化 </button> <button onClick={() => deleteUser(user.id)}> 削除 </button> </div> ))} </div> <div className="pagination"> ページ {pagination.page} / {pagination.totalPages} </div> </div> );}
特化したフックを使うことで、より使いやすく、保守しやすいコードが書けます。
楽観的更新フック
楽観的更新とは、APIの結果を待たずに即座にUIを更新する手法です。
// useOptimisticUpdate.js - 楽観的更新フックfunction useOptimisticUpdate(apiEndpoint, initialData = []) { const [data, setData] = useState(initialData); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const optimisticUpdate = useCallback(async (operation, optimisticValue, apiCall) => { // 即座にUIを更新(楽観的更新) const previousData = data; try { setData(optimisticValue); setError(null); // バックグラウンドでAPI呼び出し const result = await apiCall(); // API結果でデータを更新 setData(result); return result; } catch (err) { // エラー時は元のデータに戻す setData(previousData); setError(err.message); throw err; } }, [data]); const addItem = useCallback(async (newItem) => { const tempId = `temp-${Date.now()}`; const optimisticItem = { ...newItem, id: tempId, status: 'pending' }; return optimisticUpdate( 'add', [...data, optimisticItem], async () => { const response = await fetch(apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newItem) }); if (!response.ok) throw new Error('追加に失敗しました'); const createdItem = await response.json(); return data.map(item => item.id === tempId ? createdItem : item ).concat(createdItem.id === tempId ? [] : [createdItem]); } ); }, [data, apiEndpoint, optimisticUpdate]); const updateItem = useCallback(async (itemId, updates) => { return optimisticUpdate( 'update', data.map(item => item.id === itemId ? { ...item, ...updates, status: 'pending' } : item ), async () => { const response = await fetch(`${apiEndpoint}/${itemId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); if (!response.ok) throw new Error('更新に失敗しました'); const updatedItem = await response.json(); return data.map(item => item.id === itemId ? updatedItem : item ); } ); }, [data, apiEndpoint, optimisticUpdate]); const deleteItem = useCallback(async (itemId) => { return optimisticUpdate( 'delete', data.filter(item => item.id !== itemId), async () => { const response = await fetch(`${apiEndpoint}/${itemId}`, { method: 'DELETE' }); if (!response.ok) throw new Error('削除に失敗しました'); return data.filter(item => item.id !== itemId); } ); }, [data, apiEndpoint, optimisticUpdate]); return { data, loading, error, addItem, updateItem, deleteItem, setData };}
楽観的更新を使うと、とてもレスポンシブなUIが作れます。 ユーザーの操作に即座に反応するので、アプリが高速に感じられます。
// 使用例 - TodoListfunction TodoList() { const { data: todos, loading, error, addItem, updateItem, deleteItem } = useOptimisticUpdate('/api/todos', []); const handleAddTodo = async (text) => { try { await addItem({ text, completed: false }); } catch (error) { alert('TODOの追加に失敗しました'); } }; const toggleTodo = async (todoId, completed) => { try { await updateItem(todoId, { completed }); } catch (error) { alert('TODOの更新に失敗しました'); } }; const handleDeleteTodo = async (todoId) => { try { await deleteItem(todoId); } catch (error) { alert('TODOの削除に失敗しました'); } }; return ( <div className="todo-list"> <h1>TODO List</h1> {error && <div className="error">エラー: {error}</div>} <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.target); handleAddTodo(formData.get('text')); e.target.reset(); }}> <input name="text" placeholder="新しいTODO" required /> <button type="submit">追加</button> </form> <ul> {todos.map(todo => ( <li key={todo.id} className={todo.status === 'pending' ? 'pending' : ''}> <input type="checkbox" checked={todo.completed} onChange={(e) => toggleTodo(todo.id, e.target.checked)} /> <span className={todo.completed ? 'completed' : ''}>{todo.text}</span> <button onClick={() => handleDeleteTodo(todo.id)}>削除</button> </li> ))} </ul> </div> );}
ボタンをクリックすると、即座にUIが更新されます。 バックグラウンドでAPIが実行され、エラーがあれば元に戻されます。
カスタムフックを使用することで、非同期処理のロジックを再利用可能な形で管理できます。
イベントハンドラーでの非同期処理
ユーザーのアクションに応答する非同期処理の実装方法を、実践的なパターンとともに解説します。
基本的なイベントハンドラー
まずは、フォーム送信の基本的な実装から見てみましょう。
function FormComponent() { const [formData, setFormData] = useState({ name: '', email: '', message: '' }); const [submitting, setSubmitting] = useState(false); const [submitResult, setSubmitResult] = useState(null); // フォーム送信のイベントハンドラー const handleSubmit = async (event) => { event.preventDefault(); try { setSubmitting(true); setSubmitResult(null); // バリデーション if (!formData.name || !formData.email || !formData.message) { throw new Error('すべての項目を入力してください'); } // API送信 const response = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(formData) }); if (!response.ok) { throw new Error(`送信エラー: ${response.status}`); } const result = await response.json(); setSubmitResult({ type: 'success', message: 'お問い合わせを送信しました' }); // フォームをリセット setFormData({ name: '', email: '', message: '' }); } catch (error) { setSubmitResult({ type: 'error', message: error.message }); } finally { setSubmitting(false); } }; const handleInputChange = (event) => { const { name, value } = event.target; setFormData(prev => ({ ...prev, [name]: value })); }; return ( <form onSubmit={handleSubmit} className="contact-form"> <div className="form-group"> <label htmlFor="name">名前</label> <input id="name" name="name" type="text" value={formData.name} onChange={handleInputChange} disabled={submitting} required /> </div> <div className="form-group"> <label htmlFor="email">メールアドレス</label> <input id="email" name="email" type="email" value={formData.email} onChange={handleInputChange} disabled={submitting} required /> </div> <div className="form-group"> <label htmlFor="message">メッセージ</label> <textarea id="message" name="message" value={formData.message} onChange={handleInputChange} disabled={submitting} required rows={5} /> </div> {submitResult && ( <div className={`result ${submitResult.type}`}> {submitResult.message} </div> )} <button type="submit" disabled={submitting}> {submitting ? '送信中...' : '送信'} </button> </form> );}
この実装のポイントを見てみましょう。
submitting状態で、送信中はフォームを無効化しています。 これで、ユーザーが重複送信するのを防げます。
バリデーションを事前に行って、無効なデータを送信しないようにしています。
適切なエラーハンドリングで、ユーザーに分かりやすいメッセージを表示しています。
ファイルアップロード
ファイルアップロードは、少し複雑な非同期処理です。
function FileUploadComponent() { const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState({}); const [uploadResults, setUploadResults] = useState([]); const handleFileSelect = (event) => { const selectedFiles = Array.from(event.target.files); setFiles(selectedFiles); setUploadResults([]); }; const uploadFile = async (file, index) => { const formData = new FormData(); formData.append('file', file); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // プログレス監視 xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percentComplete = (event.loaded / event.total) * 100; setUploadProgress(prev => ({ ...prev, [index]: percentComplete })); } }; xhr.onload = () => { if (xhr.status === 200) { try { const response = JSON.parse(xhr.responseText); resolve(response); } catch (error) { reject(new Error('レスポンスの解析に失敗')); } } else { reject(new Error(`アップロードエラー: ${xhr.status}`)); } }; xhr.onerror = () => { reject(new Error('ネットワークエラー')); }; xhr.open('POST', '/api/upload'); xhr.send(formData); }); }; const handleUpload = async () => { if (files.length === 0) { alert('ファイルを選択してください'); return; } try { setUploading(true); setUploadProgress({}); setUploadResults([]); // 並行アップロード(最大3つまで) const chunkSize = 3; const results = []; for (let i = 0; i < files.length; i += chunkSize) { const chunk = files.slice(i, i + chunkSize); const chunkPromises = chunk.map((file, chunkIndex) => uploadFile(file, i + chunkIndex) .then(result => ({ success: true, file: file.name, result })) .catch(error => ({ success: false, file: file.name, error: error.message })) ); const chunkResults = await Promise.all(chunkPromises); results.push(...chunkResults); // 部分的な結果を表示 setUploadResults(prev => [...prev, ...chunkResults]); } // 成功・失敗の集計 const successCount = results.filter(r => r.success).length; const failureCount = results.filter(r => !r.success).length; alert( `アップロード完了: 成功 ${successCount}件, 失敗 ${failureCount}件` ); } catch (error) { alert(`アップロードエラー: ${error.message}`); } finally { setUploading(false); } }; return ( <div className="file-upload"> <h2>ファイルアップロード</h2> <div className="upload-controls"> <input type="file" multiple onChange={handleFileSelect} disabled={uploading} accept="image/*,.pdf,.doc,.docx" /> <button onClick={handleUpload} disabled={uploading || files.length === 0} > {uploading ? 'アップロード中...' : 'アップロード開始'} </button> </div> {files.length > 0 && ( <div className="file-list"> <h3>選択されたファイル</h3> {files.map((file, index) => ( <div key={index} className="file-item"> <span className="file-name">{file.name}</span> <span className="file-size"> ({(file.size / 1024 / 1024).toFixed(2)} MB) </span> {uploading && uploadProgress[index] !== undefined && ( <div className="progress-bar"> <div className="progress-fill" style={{ width: `${uploadProgress[index]}%` }} /> <span className="progress-text"> {Math.round(uploadProgress[index])}% </span> </div> )} </div> ))} </div> )} {uploadResults.length > 0 && ( <div className="upload-results"> <h3>アップロード結果</h3> {uploadResults.map((result, index) => ( <div key={index} className={`result-item ${result.success ? 'success' : 'error'}`} > <span className="file-name">{result.file}</span> {result.success ? ( <span className="status">✓ 成功</span> ) : ( <span className="status">✗ {result.error}</span> )} </div> ))} </div> )} </div> );}
ファイルアップロードでは、プログレス表示が重要です。 ユーザーに進捗を知らせることで、安心してもらえます。
並行アップロードで処理時間を短縮しつつ、サーバーに負荷をかけすぎないよう制御しています。
詳細な結果表示で、どのファイルが成功・失敗したかを明確にしています。
検索機能(デバウンス付き)
リアルタイム検索では、デバウンスが重要です。
function SearchComponent() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); // デバウンス用のref const debounceRef = useRef(null); const abortControllerRef = useRef(null); const performSearch = useCallback(async (searchQuery) => { if (!searchQuery.trim()) { setResults([]); setSearching(false); return; } // 前回のリクエストをキャンセル if (abortControllerRef.current) { abortControllerRef.current.abort(); } abortControllerRef.current = new AbortController(); try { setSearching(true); setError(null); const response = await fetch( `/api/search?q=${encodeURIComponent(searchQuery)}`, { signal: abortControllerRef.current.signal } ); if (!response.ok) { throw new Error('検索に失敗しました'); } const data = await response.json(); setResults(data.results || []); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); setResults([]); } } finally { if (!abortControllerRef.current?.signal.aborted) { setSearching(false); } } }, []); const handleSearchChange = async (event) => { const newQuery = event.target.value; setQuery(newQuery); // 前回のタイマーをクリア if (debounceRef.current) { clearTimeout(debounceRef.current); } // デバウンス: 300ms後に検索実行 debounceRef.current = setTimeout(() => { performSearch(newQuery); }, 300); }; const handleSearchSubmit = async (event) => { event.preventDefault(); // タイマーをクリアして即座に検索 if (debounceRef.current) { clearTimeout(debounceRef.current); } await performSearch(query); }; const handleResultClick = async (result) => { try { // クリック追跡 await fetch('/api/search/track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, resultId: result.id, position: results.indexOf(result) }) }); // 結果ページに移動 window.location.href = result.url; } catch (error) { console.error('Track search click failed:', error); // エラーでも移動は続行 window.location.href = result.url; } }; // クリーンアップ useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, []); return ( <div className="search-component"> <form onSubmit={handleSearchSubmit} className="search-form"> <div className="search-input-container"> <input type="text" value={query} onChange={handleSearchChange} placeholder="検索キーワードを入力..." className="search-input" /> {searching && ( <div className="search-spinner">検索中...</div> )} <button type="submit" className="search-button"> 検索 </button> </div> </form> {error && ( <div className="search-error"> エラー: {error} </div> )} {results.length > 0 && ( <div className="search-results"> <h3>検索結果 ({results.length}件)</h3> {results.map((result, index) => ( <div key={result.id} className="search-result-item" onClick={() => handleResultClick(result)} > <h4 className="result-title">{result.title}</h4> <p className="result-description">{result.description}</p> <span className="result-url">{result.url}</span> </div> ))} </div> )} {query && !searching && results.length === 0 && !error && ( <div className="no-results"> 「{query}」に一致する結果が見つかりませんでした </div> )} </div> );}
デバウンスを使うことで、無駄なAPIリクエストを減らせます。 ユーザーが入力を止めてから300ms後に検索を実行します。
リクエストキャンセルで、古い検索結果が新しい結果を上書きするのを防いでいます。
クリック追跡で、検索の分析データを収集できます。
イベントハンドラーでの非同期処理では、ユーザビリティとエラーハンドリングを重視した実装が重要です。
まとめ
この記事では、Reactにおける非同期処理とasync/awaitの正しい使い方について詳しく解説しました。
重要なポイント
-
Reactでのasync/await制限の理解
- コンポーネント関数にasyncは使えない(Promiseを返すため)
- useEffectに直接asyncは使えない(クリーンアップ関数の問題)
- イベントハンドラーではasync/awaitが使用可能
-
useEffectでの正しい非同期処理
// ✅ 正しいパターンuseEffect(() => { const fetchData = async () => { try { const response = await fetch('/api/data'); const result = await response.json(); setData(result); } catch (error) { setError(error.message); } }; fetchData();}, []);
-
カスタムフックによる再利用可能な非同期処理
- ロジックの分離と再利用
- 楽観的更新の実装
- キャッシュ機能の追加
- エラーハンドリングの統一
-
イベントハンドラーでの実践的パターン
- フォーム送信の処理
- ファイルアップロード
- 検索機能(デバウンス付き)
- 一括操作
ベストプラクティス
// 包括的な非同期処理の実装例function AsyncComponent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const abortController = new AbortController(); const fetchData = async () => { try { setLoading(true); setError(null); const response = await fetch('/api/data', { signal: abortController.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const result = await response.json(); setData(result); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { if (!abortController.signal.aborted) { setLoading(false); } } }; fetchData(); return () => { abortController.abort(); }; }, []); // UI実装...}
推奨アプローチ
-
データフェッチ
- useEffectでのasync関数定義パターン
- AbortControllerによるキャンセル対応
- 適切なエラーハンドリング
-
状態管理
- loading、error、dataの適切な管理
- 楽観的更新の活用
- キャッシュ戦略の実装
-
ユーザビリティ
- ローディング状態の表示
- エラーメッセージの親切な表示
- 再試行機能の提供
-
パフォーマンス
- 不要なリクエストの防止
- デバウンス・スロットリングの活用
- 並行処理の最適化
避けるべきアンチパターン
// ❌ 避けるべきパターンasync function BadComponent() { const data = await fetch('/api/data'); return <div>{data}</div>;}
useEffect(async () => { const data = await fetchData(); setData(data);}, []);
// ✅ 正しいパターンfunction GoodComponent() { const [data, setData] = useState(null); useEffect(() => { const fetchData = async () => { const response = await fetch('/api/data'); const result = await response.json(); setData(result); }; fetchData(); }, []); return <div>{data}</div>;}
今後の学習
- React Query / TanStack Query: より高度なデータフェッチ
- SWR: データフェッチライブラリ
- React Suspense: 非同期処理のUIパターン
- React Server Components: サーバーサイドでの非同期処理
Reactでの非同期処理は、適切なパターンを理解することで安全で効率的な実装が可能です。 この知識を活用して、ユーザーフレンドリーなアプリケーションを開発してください。