Reactのライフサイクルとは?Hooksで簡単に理解する
ReactコンポーネントのライフサイクルをuseEffectとHooksを使って分かりやすく解説。マウント、更新、アンマウントの仕組みを初心者向けに説明します。
みなさん、Reactのライフサイクルという言葉を聞いたことはありますか?
「ライフサイクルって何?」「useEffectとの関係がわからない」と思ったことはありませんか?
この記事では、Reactのライフサイクルを現代的なHooksを使って分かりやすく解説します。 難しそうに見えますが、実は簡単に理解できるんです。
一緒にコンポーネントの「一生」について学んでいきましょう!
ライフサイクルとは
コンポーネントの「一生」
ライフサイクルとは、コンポーネントが生まれてから消えるまでの流れのことです。
簡単に言うと、コンポーネントには3つの段階があります。
- マウント:コンポーネントが画面に表示される
- 更新:propsやstateが変更される
- アンマウント:コンポーネントが画面から削除される
人の一生みたいですよね。 生まれて、成長して、最後にお別れするという感じです。
// ライフサイクルのイメージ
const MyComponent = () => {
console.log('コンポーネントがレンダリングされました');
useEffect(() => {
console.log('マウント:画面に表示されました');
return () => {
console.log('アンマウント:画面から削除されます');
};
}, []);
return <div>Hello World</div>;
};
このコードでは、コンポーネントが画面に表示されるときと削除されるときにメッセージが表示されます。
useEffect
を使って、コンポーネントのライフサイクルを管理しているんです。
useEffectでライフサイクルを理解する
useEffectを使えば、ライフサイクルの各段階を簡単に扱えます。
1. マウント(componentDidMount相当)
コンポーネントが最初に画面に表示されるときの処理です。
const MountExample = () => {
const [data, setData] = useState(null);
// マウント時に1回だけ実行
useEffect(() => {
console.log('コンポーネントがマウントされました');
// 初期データの取得
fetchInitialData();
async function fetchInitialData() {
try {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
} catch (error) {
console.error('データ取得エラー:', error);
}
}
}, []); // 空の依存配列 = マウント時のみ
return (
<div>
{data ? (
<p>データ: {data.message}</p>
) : (
<p>読み込み中...</p>
)}
</div>
);
};
依存配列が空([]
)の場合、マウント時に1回だけ実行されます。
fetchInitialData
関数でAPIからデータを取得しています。
async/await
を使って非同期処理を行い、取得したデータをsetData
で状態に保存します。
エラーが発生した場合は、catch
文でエラーメッセージを表示します。
2. 更新(componentDidUpdate相当)
propsやstateが変更されたときの処理です。
const UpdateExample = () => {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// countが変更されるたびに実行
useEffect(() => {
console.log('countが更新されました:', count);
// ローカルストレージに保存
localStorage.setItem('count', count.toString());
}, [count]); // countを依存配列に指定
// nameが変更されるたびに実行
useEffect(() => {
console.log('nameが更新されました:', name);
// APIでプロフィール更新
if (name) {
updateProfile(name);
}
}, [name]); // nameを依存配列に指定
const updateProfile = async (newName) => {
try {
await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
} catch (error) {
console.error('プロフィール更新エラー:', error);
}
};
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
カウントアップ
</button>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前を入力"
/>
</div>
);
};
依存配列に変数を指定すると、その変数が変更されるたびに実行されます。
最初のuseEffectでは、count
が変更されるたびにローカルストレージに保存しています。
2つ目のuseEffectでは、name
が変更されるたびにAPIでプロフィールを更新します。
複数のuseEffectを使うことで、異なる変更を別々に管理できるんです。
3. アンマウント(componentWillUnmount相当)
コンポーネントが画面から削除されるときの処理です。
const UnmountExample = () => {
const [time, setTime] = useState(new Date());
useEffect(() => {
console.log('タイマーを開始します');
// タイマーの設定
const timer = setInterval(() => {
setTime(new Date());
}, 1000);
// クリーンアップ関数(アンマウント時に実行)
return () => {
console.log('タイマーを停止します');
clearInterval(timer);
};
}, []);
return <div>現在時刻: {time.toLocaleTimeString()}</div>;
};
クリーンアップ関数(return () => {}
)を使って、アンマウント時の処理を定義します。
setInterval
でタイマーを開始し、1秒ごとに現在時刻を更新しています。
コンポーネントがアンマウントされるときに、clearInterval
でタイマーを停止します。
このクリーンアップ処理をしないと、メモリリークの原因になってしまいます。
実際のライフサイクルパターン
よく使われるライフサイクルのパターンを見てみましょう。
パターン1: データ取得とクリーンアップ
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true; // マウント状態の管理
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
// マウント状態を確認してから更新
if (isMounted) {
setUser(userData);
}
} catch (err) {
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchUser();
// クリーンアップ
return () => {
isMounted = false;
};
}, [userId]); // userIdが変更されたら再実行
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error}</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
};
このパターンでは、isMounted
フラグでコンポーネントがまだ存在するかチェックしています。
fetchUser
関数でAPIからユーザーデータを取得します。
非同期処理が完了する前にコンポーネントがアンマウントされた場合、状態の更新をスキップします。
userId
が変更されると、新しいユーザーのデータを取得し直します。
パターン2: イベントリスナーの管理
const WindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
console.log('リサイズイベントを登録します');
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
// イベントリスナーを登録
window.addEventListener('resize', handleResize);
// クリーンアップでイベントリスナーを削除
return () => {
console.log('リサイズイベントを削除します');
window.removeEventListener('resize', handleResize);
};
}, []); // マウント時のみ実行
return (
<div>
ウィンドウサイズ: {windowSize.width} x {windowSize.height}
</div>
);
};
handleResize
関数でウィンドウサイズの変更を検知します。
addEventListener
でイベントリスナーを登録し、ウィンドウサイズが変更されるたびに状態を更新します。
クリーンアップ関数でremoveEventListener
を呼び出し、イベントリスナーを削除します。
これにより、メモリリークを防げます。
パターン3: 複数の依存関係を持つ更新
const SearchResults = ({ query, category, sortBy }) => {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// 検索パラメータが変更されたら検索を実行
const performSearch = async () => {
if (!query) {
setResults([]);
return;
}
setLoading(true);
try {
const params = new URLSearchParams({
q: query,
category,
sort: sortBy
});
const response = await fetch(`/api/search?${params}`);
const data = await response.json();
setResults(data.results);
} catch (error) {
console.error('検索エラー:', error);
setResults([]);
} finally {
setLoading(false);
}
};
performSearch();
}, [query, category, sortBy]); // 3つの値が変更されたら再実行
return (
<div>
{loading ? (
<div>検索中...</div>
) : (
<div>
{results.map(item => (
<div key={item.id}>{item.title}</div>
))}
</div>
)}
</div>
);
};
複数の値を依存配列に指定することで、どれか1つでも変更されたら処理を実行できます。
performSearch
関数で検索処理を行います。
URLSearchParams
でクエリパラメータを作成し、APIにリクエストを送信します。
検索中はloading
をtrue
にして、ローディング表示を行います。
クラスコンポーネントとの対比
クラスコンポーネントと関数コンポーネントの違いを見てみましょう。
クラスコンポーネント(旧式)
class OldComponent extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
// マウント時
componentDidMount() {
console.log('マウントされました');
this.timer = setInterval(this.tick, 1000);
}
// 更新時
componentDidUpdate(prevProps, prevState) {
if (prevState.count !== this.state.count) {
console.log('countが更新されました');
}
}
// アンマウント時
componentWillUnmount() {
console.log('アンマウントされます');
clearInterval(this.timer);
}
tick = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return <div>Count: {this.state.count}</div>;
}
}
クラスコンポーネントでは、それぞれのライフサイクルに対応したメソッドを定義します。
componentDidMount
、componentDidUpdate
、componentWillUnmount
などです。
関数コンポーネント(現代的)
const ModernComponent = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('マウントされました');
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => {
console.log('アンマウントされます');
clearInterval(timer);
};
}, []);
useEffect(() => {
console.log('countが更新されました');
}, [count]);
return <div>Count: {count}</div>;
};
関数コンポーネントでは、useEffectだけで全てのライフサイクルを管理できます。 より簡潔で理解しやすいコードになりますね。
クラスコンポーネントと比べて、書くコードの量も少なくなります。
実践的な活用例
実際のアプリケーションでよく使われるパターンを見てみましょう。
1. データ同期システム
const DataSync = ({ endpoint, interval = 5000 }) => {
const [data, setData] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
useEffect(() => {
let mounted = true;
const syncData = async () => {
try {
const response = await fetch(endpoint);
const newData = await response.json();
if (mounted) {
setData(newData);
setLastUpdated(new Date());
}
} catch (error) {
console.error('同期エラー:', error);
}
};
// 初回実行
syncData();
// 定期同期
const timer = setInterval(syncData, interval);
return () => {
mounted = false;
clearInterval(timer);
};
}, [endpoint, interval]);
return (
<div>
<h3>データ同期</h3>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
{lastUpdated && (
<p>最終更新: {lastUpdated.toLocaleTimeString()}</p>
)}
</div>
);
};
このコンポーネントでは、定期的にAPIからデータを取得して同期しています。
syncData
関数で非同期でデータを取得し、成功したら状態を更新します。
setInterval
で定期的に同期処理を実行します。
mounted
フラグで、コンポーネントがアンマウントされた後の状態更新を防いでいます。
2. 状態の永続化
const PersistentCounter = () => {
const [count, setCount] = useState(() => {
// 初期値をローカルストレージから取得
const saved = localStorage.getItem('counter');
return saved ? parseInt(saved, 10) : 0;
});
// countが変更されるたびにローカルストレージに保存
useEffect(() => {
localStorage.setItem('counter', count.toString());
}, [count]);
// ページを離れる前に確認
useEffect(() => {
const handleBeforeUnload = (e) => {
if (count > 0) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [count]);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
);
};
useState
の初期値を関数で指定して、ローカルストレージから値を復元しています。
1つ目のuseEffectで、count
が変更されるたびにローカルストレージに保存します。
2つ目のuseEffectで、ページを離れる前に確認ダイアログを表示します。
ページをリロードしても、カウントの値が保持されるようになります。
3. 外部ライブラリとの連携
const MapComponent = ({ center, zoom }) => {
const mapRef = useRef(null);
const [map, setMap] = useState(null);
// マップの初期化
useEffect(() => {
if (mapRef.current && !map) {
const newMap = new google.maps.Map(mapRef.current, {
center,
zoom
});
setMap(newMap);
}
}, [map, center, zoom]);
// centerが変更されたらマップを移動
useEffect(() => {
if (map && center) {
map.setCenter(center);
}
}, [map, center]);
// zoomが変更されたらズームレベルを調整
useEffect(() => {
if (map && zoom) {
map.setZoom(zoom);
}
}, [map, zoom]);
return <div ref={mapRef} style={{ width: '100%', height: '400px' }} />;
};
Google Mapsのような外部ライブラリとの連携例です。
最初のuseEffectでマップを初期化し、状態に保存します。
2つ目のuseEffectで、center
が変更されたらマップの中心を移動します。
3つ目のuseEffectで、zoom
が変更されたらズームレベルを調整します。
複数のuseEffectを使って、異なるpropsの変更を個別に処理しています。
よくある間違いと対処法
useEffectを使う際によくある間違いを見てみましょう。
間違い1: 依存配列の不備
// ❌ 問題のあるコード
const BadExample = ({ userId }) => {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // userIdを依存配列に入れていない
return <div>{user?.name}</div>;
};
// ✅ 正しいコード
const GoodExample = ({ userId }) => {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // userIdを依存配列に追加
return <div>{user?.name}</div>;
};
依存配列にuseEffectで使用している変数を含めないと、期待通りに動作しません。
BadExampleでは、userId
が変更されても新しいユーザーデータを取得しません。
GoodExampleでは、userId
が変更されるたびに新しいデータを取得します。
間違い2: クリーンアップの忘れ
// ❌ 問題のあるコード
const BadTimer = () => {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// クリーンアップがない
}, []);
return <div>{count}</div>;
};
// ✅ 正しいコード
const GoodTimer = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer); // クリーンアップを追加
}, []);
return <div>{count}</div>;
};
タイマーやイベントリスナーなどは、必ずクリーンアップが必要です。
BadTimerでは、タイマーが停止されずにメモリリークが発生します。 GoodTimerでは、コンポーネントがアンマウントされるときにタイマーを停止します。
クリーンアップを忘れると、アプリのパフォーマンスが悪化してしまいます。
まとめ
Reactのライフサイクルは、useEffectを使えば簡単に理解できます。
基本的なパターンをおさらいしましょう。
マウント: useEffect(() => {}, [])
- 空の依存配列
更新: useEffect(() => {}, [value])
- 監視したい値を依存配列に
アンマウント: return () => {}
- クリーンアップ関数
重要なポイントをまとめます。
- 依存配列を正しく設定しましょう
- 必要に応じてクリーンアップ関数を実装しましょう
- 非同期処理では適切な状態管理を行いましょう
現代のReact開発では、関数コンポーネント+Hooksが主流です。 クラスコンポーネントよりもシンプルで理解しやすいコードが書けます。
ライフサイクルの概念を理解することで、より効率的で安全なReactアプリケーションを構築できます。 ぜひuseEffectを活用して、ライフサイクルを意識したコンポーネント設計を心がけてくださいね!