Reactの基礎 - useEffect
概要
useEffect は、Reactの関数コンポーネントで副作用 (side effects) を扱うための標準的なHookである。
副作用とは、コンポーネントのレンダリング自体とは直接関係のない処理を指し、データフェッチ、DOM操作、タイマの設定、イベントリスナーの登録等が該当する。
React 16.8以前のクラスコンポーネントでは、ライフサイクルメソッド (componentDidMount、componentDidUpdate、componentWillUnmount) を使用して副作用を管理していた。
useEffect はこれら3つのライフサイクルメソッドの役割を1つのAPIに統合したものである。
基本構文は useEffect(setup, dependencies?) であり、第1引数にEffectのロジックを記述した関数、第2引数に依存配列をオプションで指定する。
依存配列の指定方法によって、Effectが実行されるタイミングを制御する。
また、setup関数からクリーンアップ関数を返すことにより、コンポーネントのアンマウント時やEffectの再実行前に後処理を実行できる。
クリーンアップ関数を正しく実装することで、メモリリークや意図しない副作用の蓄積を防止できる。
なお、useEffect はReact 18以降の開発モード (StrictMode) では意図的に2回実行される。
これはクリーンアップ関数の正しい実装を検証するための仕組みであり、本番環境では1回のみ実行される。
依存配列の制御
useEffect の第2引数に渡す依存配列によって、Effectが実行されるタイミングが変化する。
依存配列なし
依存配列を省略すると、コンポーネントのレンダリングごとにEffectが実行される。
import { useEffect } from 'react';
function ExampleComponent() {
useEffect(() => {
// 全レンダリング後に実行される
console.log('レンダリングが実行された');
});
return <div>コンポーネント</div>;
}
依存配列なしのEffectは毎回実行されるため、データフェッチや重い処理に使用するとパフォーマンス上の問題が生じる場合がある。
通常は依存配列を明示的に指定することを推奨する。
空配列 []
空配列を指定すると、コンポーネントのマウント時に1回だけEffectが実行される。
クラスコンポーネントの componentDidMount に相当する動作である。
import { useEffect, useState } from 'react';
function DataLoader() {
const [data, setData] = useState<string[]>([]);
useEffect(() => {
// マウント時に1回だけ実行される
console.log('コンポーネントがマウントされた');
fetchInitialData().then(setData);
}, []);
return <ul>{data.map((item, i) => <li key={i}>{item}</li>)}</ul>;
}
特定の値を指定
依存配列に特定の値を指定すると、それらの値が変更された時にEffectが実行される。
クラスコンポーネントの componentDidUpdate に相当する動作である。
import { useEffect, useState } from 'react';
interface UserProfileProps {
userId: number;
}
function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<{ name: string } | null>(null);
useEffect(() => {
// userId が変更されるたびに実行される
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user?.name ?? '読み込み中...'}</div>;
}
依存配列に指定する値は、Effectの内部で参照する全てのリアクティブ値 (props、state、コンポーネント内で宣言された変数・関数) を含める必要がある。
クリーンアップ関数
useEffect の setup関数からクリーンアップ関数を返すことで、以下に示すタイミングで後処理を実行できる。
- コンポーネントがアンマウントされる時
- Effectが再実行される前 (前回のEffectのクリーンアップが先に行われる)
タイマのクリーンアップ
setInterval や setTimeout を使用する場合は、クリーンアップ関数で必ず解除する必要がある。
解除しない場合、コンポーネントのアンマウント後もタイマが動作し続け、メモリリークや意図しない状態更新の原因となる。
import { useEffect, useState } from 'react';
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// クリーンアップ関数でタイマを解除する
return () => {
clearInterval(intervalId);
};
}, []);
return <div>経過秒数: {count}秒</div>;
}
イベントリスナーの解除
addEventListener で登録したイベントリスナーは、クリーンアップ関数で removeEventListener を使用して解除する。
import { useEffect, useState } from 'react';
function WindowSizeTracker() {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// クリーンアップ関数でイベントリスナーを解除する
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>ウィンドウ幅: {windowWidth}px</div>;
}
WebSocket・サブスクリプションの切断
WebSocketやリアルタイムサブスクリプションを使用する場合も、クリーンアップ関数で切断処理を定義する。
import { useEffect, useState } from 'react';
interface Message {
id: number;
text: string;
}
function ChatRoom() {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = new WebSocket('wss://example.com/chat');
ws.onmessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data) as Message;
setMessages(prev => [...prev, msg]);
};
// クリーンアップ関数でWebSocketを切断する
return () => {
ws.close();
};
}, []);
return (
<ul>
{messages.map(msg => <li key={msg.id}>{msg.text}</li>)}
</ul>
);
}
データフェッチング
useEffect を使用したデータフェッチングは一般的なパターンである。
ただし、useEffect 自体は非同期関数を直接返すことができないため、内部で非同期関数を定義して呼び出す形式で記述する。
基本的なfetchパターン
データフェッチングでは、コンポーネントのアンマウント後にstateを更新しないよう ignore フラグを使用して競合状態 (race condition) を防止することが重要である。
import { useEffect, useState } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
interface PostDetailProps {
postId: number;
}
function PostDetail({ postId }: PostDetailProps) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// ignoreフラグで競合状態を防止する
let ignore = false;
setLoading(true);
setError(null);
async function fetchPost() {
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
const data = await response.json() as Post;
if (!ignore) {
setPost(data);
}
}
catch (err) {
if (!ignore) {
setError('データの取得に失敗した');
}
}
finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchPost();
// クリーンアップ関数でignoreフラグをtrueにする
return () => {
ignore = true;
};
}, [postId]);
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error}</div>;
if (!post) return null;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
AbortControllerによるキャンセル
AbortController を使用すると、コンポーネントのアンマウント時に進行中のfetchリクエストをキャンセルできる。
ignore フラグと組み合わせることにより、より安全なデータフェッチングを実装できる。
import { useEffect, useState } from 'react';
interface SearchResult {
id: number;
name: string;
}
interface SearchProps {
query: string;
}
function SearchResults({ query }: SearchProps) {
const [results, setResults] = useState<SearchResult[]>([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
async function fetchResults() {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
});
const data = await response.json() as SearchResult[];
setResults(data);
}
catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('検索エラー:', err);
}
}
}
fetchResults();
// クリーンアップ関数でリクエストをキャンセルする
return () => {
controller.abort();
};
}, [query]);
return (
<ul>
{results.map(result => <li key={result.id}>{result.name}</li>)}
</ul>
);
}
useEffect と useLayoutEffectの違い
Reactには useEffect と似たHookとして useLayoutEffect が存在する。
両者の主な違いは実行タイミングである。
| 項目 | useEffect | useLayoutEffect |
|---|---|---|
| 実行タイミング | ブラウザの画面描画後に非同期で実行 | Webブラウザの画面描画前に同期で実行 |
| パフォーマンスへの影響 | 描画をブロックしないため影響が少ない。 | 描画をブロックするため影響が大きい。 |
| 主な用途 | データフェッチ、イベント登録、外部連携 | DOM計測、スクロール位置調整、ちらつき防止 |
| SSRでの動作 | 通常通り動作する | サーバサイドでは実行されない。(警告が発生する場合がある) |
useLayoutEffect を使用するのは、画面のちらつきを防止する必要がある場合に限定することを推奨する。
例えば、DOMの計測値に基づいてスタイルを同期的に適用する場合等が該当する。
通常の副作用処理には useEffect を使用し、DOM計測やちらつき防止が必要な場合のみ useLayoutEffect を使用する。
import { useLayoutEffect, useRef, useState } from 'react';
function TooltipWithPosition() {
const ref = useRef<HTMLDivElement>(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
// DOM計測を描画前に行うためuseLayoutEffectを使用する
useLayoutEffect(() => {
if (ref.current) {
setTooltipHeight(ref.current.offsetHeight);
}
}, []);
return (
<div>
<div ref={ref}>ツールチップコンテンツ</div>
<p>ツールチップの高さ: {tooltipHeight}px</p>
</div>
);
}
Tauri v2との統合
Tauri v2アプリケーションでは、useEffect 内でTauriの invoke 関数を呼び出して、Rustバックエンドのコマンドと連携することができる。
invokeコマンドの呼び出し
@tauri-apps/api/core から invoke をインポートして、useEffect 内で非同期処理として呼び出す。
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
interface SystemInfo {
os: string;
arch: string;
version: string;
}
function SystemInfoDisplay() {
const [sysInfo, setSysInfo] = useState<SystemInfo | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
async function loadSystemInfo() {
try {
// Rustバックエンドのget_system_infoコマンドを呼び出す
const info = await invoke<SystemInfo>('get_system_info');
if (!ignore) {
setSysInfo(info);
}
}
catch (err) {
if (!ignore) {
setError(`システム情報の取得に失敗した: ${err}`);
}
}
}
loadSystemInfo();
return () => {
ignore = true;
};
}, []);
if (error) return <div>エラー: {error}</div>;
if (!sysInfo) return <div>読み込み中...</div>;
return (
<div>
<p>OS: {sysInfo.os}</p>
<p>アーキテクチャ: {sysInfo.arch}</p>
<p>バージョン: {sysInfo.version}</p>
</div>
);
}
propsの変化に応じてバックエンドコマンドを再呼び出しする場合は、依存配列に該当するpropsを指定する。
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
interface FileContentProps {
filePath: string;
}
function FileContentViewer({ filePath }: FileContentProps) {
const [content, setContent] = useState<string>('');
useEffect(() => {
let ignore = false;
async function readFile() {
try {
const text = await invoke<string>('read_file', { path: filePath });
if (!ignore) {
setContent(text);
}
}
catch (err) {
console.error('ファイル読み込みエラー:', err);
}
}
readFile();
return () => {
ignore = true;
};
}, [filePath]);
return <pre>{content}</pre>;
}
StrictModeでの2重実行
React 18以降の開発モードでは、useEffect が意図的に2回実行される。
この動作はReactのStrictModeによるものである。
StrictModeは開発時にクリーンアップ関数が正しく実装されているかを検証するため、Effectのマウント・クリーンアップ・再マウントのサイクルをシミュレートする。
具体的な実行順序は以下の通りである。
- コンポーネントがマウントされ、Effectが実行される。
- クリーンアップ関数が実行される。
- 同じコンポーネントが再マウントされ、Effectが再実行される。
この2重実行は本番環境では発生せず、開発モード (StrictMode) 限定の動作である。
import { useEffect } from 'react';
function StrictModeExample() {
useEffect(() => {
// 開発モードでは2回呼ばれる
console.log('Effect実行');
return () => {
// 開発モードでは2回呼ばれる
console.log('クリーンアップ実行');
};
}, []);
return <div>StrictModeテスト</div>;
}
2重実行が問題となる場合は、クリーンアップ関数を正しく実装することで対処する。
クリーンアップ関数が適切に実装されていれば、StrictModeでの2重実行によって不具合は生じない。
よくある間違い
useEffect の使用において頻繁に発生する間違いを以下に示す。
依存配列の欠落
Effectの内部で参照するpropsやstateを依存配列に含めない場合、古い値を参照し続けるバグが発生する可能性がある。
// 誤り : userIdを参照しているが依存配列に含まれていない
useEffect(() => {
fetchUser(userId);
}, []); // userIdが変わっても再実行されない
// 正しい : 参照している値を依存配列に含める
useEffect(() => {
fetchUser(userId);
}, [userId]);
ESLintの exhaustive-deps ルールを使用すると、依存配列の欠落を自動検出できる。
無限ループ
Effect内でstateを更新し、そのstateが依存配列に含まれている場合、無限ループが発生する。
// 誤り : dataを更新するEffectがdataに依存しているため無限ループが発生する
const [data, setData] = useState([]);
useEffect(() => {
setData([...data, 'newItem']); // dataを更新する
}, [data]); // dataが変わるたびに再実行される
// 正しい : アップデーター関数を使用してdataへの依存をなくす
useEffect(() => {
setData(prev => [...prev, 'newItem']);
}, []); // 依存配列からdataを除外できる
非同期関数の直接使用
useEffect の第1引数に非同期関数を直接渡すことはできない。
非同期関数はPromiseを返すが、useEffect はクリーンアップ関数 (または何も返さない) のみを期待しているためである。
// 誤り : useEffectに非同期関数を直接渡している
useEffect(async () => {
const data = await fetchData(); // async関数はPromiseを返すためエラーになる
setData(data);
}, []);
// 正しい : useEffect内で非同期関数を定義して呼び出す
useEffect(() => {
async function loadData() {
const data = await fetchData();
setData(data);
}
loadData();
}, []);
関連性のない処理の混在
複数の独立した副作用を1つの useEffect にまとめると、保守性が低下する。
関連性のない処理は、個別の useEffect に分離することを推奨する。
// 誤り : 無関係な2つの副作用が混在している
useEffect(() => {
fetchUserData(userId);
document.title = `ページ: ${pageTitle}`;
}, [userId, pageTitle]);
// 正しい : 独立した副作用はそれぞれのuseEffectに分離する
useEffect(() => {
fetchUserData(userId);
}, [userId]);
useEffect(() => {
document.title = `ページ: ${pageTitle}`;
}, [pageTitle]);
関連情報