Reactは、ユーザーインターフェース(UI)を構築するための宣言的なライブラリです。この「宣言的」という言葉が、Reactの哲学の根幹をなしています。私たちは「どのようにDOMを操作するか」という命令的な手順を記述するのではなく、「ある状態(State)のときにUIがどのように見えるべきか」という最終的な姿を定義します。Reactは、その状態の変更を検知し、UIを効率的に更新する役割を担います。このモデルの中心に位置するのが「状態管理」であり、React Hooksの登場により、その方法は劇的にシンプルかつパワフルになりました。この記事では、数あるフックの中でも最も基本的で重要なuseStateとuseEffectに焦点を当て、その動作原理から実践的な応用パターンまでを徹底的に掘り下げます。
かつてのReact開発では、状態を持つコンポーネントはクラスコンポーネントとして記述する必要がありました。this.stateやthis.setState、そしてcomponentDidMountやcomponentDidUpdateといったライフサイクルメソッドは、多くのReact開発者にとって馴染み深いものでした。しかし、クラスコンポーネントにはいくつかの課題がありました。thisキーワードの振る舞いが混乱を招きやすいこと、関連するロジックが複数のライフサイクルメソッドに分散してしまうこと、そしてロジックの再利用のために高階コンポーネント(HOC)やRender Propsといった複雑なパターンが必要になることなどです。これらの課題を解決するために、React 16.8で導入されたのが「フック(Hooks)」です。フックは、関数コンポーネントに状態管理や副作用実行などの機能を追加するための仕組みであり、useStateとuseEffectはその中核をなす存在です。
第1章: useState - コンポーネントに「記憶」を与える
Reactコンポーネントは本質的に、特定のpropsを受け取ってJSXを返すただの関数です。通常のJavaScript関数が実行を終えると、その内部で宣言された変数は失われます。しかし、UIコンポーネントはユーザーのインタラクションやデータの変更に応じて再描画(再レンダリング)される必要があり、その際に前回の状態を「記憶」していなければなりません。この「記憶」の役割を果たすのがuseStateです。
1.1 useStateの基本的な構文と動作原理
useStateは、関数コンポーネント内で状態変数を宣言するためのフックです。その最も基本的な使い方は以下のようになります。
import React, { useState } from 'react';
function Counter() {
// 'count'という名前の新しい状態変数を宣言
// useStateはペアを返す: 現在の状態値と、それを更新するための関数
const [count, setCount] = useState(0);
return (
You clicked {count} times
);
}
このコードにはいくつかの重要な要素が含まれています。
useState(0):useStateを呼び出すことで、新しい状態変数を「予約」します。引数として渡された0は、この状態の「初期値」です。コンポーネントが初めてレンダリングされる際に一度だけ使用されます。const [count, setCount] = ...:useStateは、現在の状態値と、その状態を更新するための関数の2つの要素を持つ配列を返します。ここではJavaScriptの「配列の分割代入(Array Destructuring)」構文を使って、それぞれをcountとsetCountという名前の変数に割り当てています。countは現在のカウンターの値(初期値は0)、setCountはcountの値を更新するための関数です。setCount(count + 1): ボタンがクリックされると、setCount関数が呼び出されます。この関数に新しい値を渡すことで、Reactに対して状態の更新をスケジュールするよう伝えます。Reactはこの更新を受け取ると、コンポーネントを再レンダリングする計画を立てます。
重要なのは、setCountを呼び出しても、即座にcount変数が変わるわけではないということです。状態の更新は非同期的に行われます。setCountはReactに「次のレンダリングサイクルで、countの値をこの新しい値にしてください」と依頼するだけです。Reactは効率化のために複数の状態更新をバッチ処理することがあり、その後、新しい状態値でコンポーネント関数(この場合はCounter)を再実行します。この再実行の際に、useStateは更新された最新のcountの値を返します。
1.2 舞台裏:Reactはどのように状態を追跡しているのか?
関数コンポーネントはただの関数なのに、なぜレンダリングをまたいで状態を保持できるのでしょうか?Reactは内部的に、各コンポーネントに関連付けられたメモリセル(あるいはフックのリスト)を保持しています。コンポーネントが初めてレンダリングされる際、useStateが呼ばれるたびに新しいメモリセルが作成され、初期値が保存されます。そして、フックが呼ばれた順序が記録されます。
コンポーネントが再レンダリングされる際、フックは再び同じ順序で呼び出されます。Reactは、この呼び出し順序を頼りに、前回どのuseStateがどのメモリセルに対応していたかを判断し、そこに保存されている現在の値を返します。これが「フックのルール」(トップレベルで呼び出す、条件分岐やループ内で呼び出さない)が非常に重要である理由です。呼び出し順序が毎回同じでなければ、Reactはフックと状態を正しく対応させることができなくなります。
1.3 関数型更新:安全な状態更新のための必須テクニック
前の例ではsetCount(count + 1)と記述しました。多くの場合、これは問題なく動作します。しかし、新しい状態が直前の状態に依存している場合、この書き方には潜在的なバグが潜んでいます。それは「古い状態(stale state)」の問題です。
以下の例を考えてみましょう。
function ComplexCounter() {
const [count, setCount] = useState(0);
const handleTripleClick = () => {
// このコードは期待通りに動作しない!
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
Count: {count}
);
}
この「+3」ボタンをクリックしても、カウンターは1しか増えません。なぜでしょうか?handleTripleClickが実行された時点でのcountの値は、例えば0です。3回のsetCount呼び出しはすべて、setCount(0 + 1)として解釈されます。Reactはこれらの更新をバッチ処理し、最終的に状態を1に更新するだけです。
この問題を解決するのが「関数型更新」です。setCountのような状態更新関数には、新しい値を直接渡す代わりに、「直前の状態を受け取り、新しい状態を返す関数」を渡すことができます。
const handleTripleClick = () => {
// これが正しい方法
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
この形式を使うと、Reactはこれらの更新をキューに入れ、順番に処理します。最初の更新は、直前の状態(例:0)を受け取り、0 + 1を返します。次の更新は、その結果である1を受け取り、1 + 1を返します。最後の更新は2を受け取り、2 + 1を返します。結果として、状態は正しく3に更新されます。新しい状態が前の状態に依存する場合は、常にこの関数型更新を使用することがベストプラクティスです。
1.4 オブジェクトと配列の状態管理:不変性(Immutability)の原則
useStateは数値や文字列だけでなく、オブジェクトや配列も状態として保持できます。しかし、これらを扱う際には注意が必要です。Reactは状態が変更されたかどうかを判断するために、オブジェクトや配列の参照を比較します。もしオブジェクトや配列を直接変更(mutate)してしまうと、参照は同じままなので、Reactは変更を検知できず、再レンダリングがトリガーされません。
間違った例(直接的な変更):
function UserProfile() {
const [user, setUser] = useState({ name: 'Alice', age: 30 });
const handleAgeIncrement = () => {
// やってはいけない! stateオブジェクトを直接変更している
user.age = user.age + 1;
setUser(user); // userの参照は変わっていないため、Reactは再レンダリングしない
};
return (
{user.name}, {user.age}
);
}
この例では、ボタンをクリックしても画面上の年齢は変わりません。
正しい例(新しいオブジェクトを作成):
状態を更新する際は、常に新しいオブジェクトや配列を作成し、それを状態設定関数に渡す必要があります。スプレッド構文(...)を使うのが一般的です。
const handleAgeIncrement = () => {
// 正しい方法! スプレッド構文で既存のプロパティをコピーし、
// 変更したいプロパティを上書きした「新しい」オブジェクトを作成する
setUser({ ...user, age: user.age + 1 });
};
配列の場合も同様です。
const [items, setItems] = useState(['apple', 'banana']);
// 新しい項目を追加する
const addItem = () => {
// 正しい: スプレッド構文で新しい配列を作成
setItems([...items, 'cherry']);
};
// 特定の項目を削除する
const removeItem = (itemToRemove) => {
// 正しい: filterメソッドは新しい配列を返す
setItems(items.filter(item => item !== itemToRemove));
};
この「不変性(Immutability)」の原則は、Reactの状態管理において極めて重要です。これにより、変更の追跡が容易になり、パフォーマンスの最適化(例: React.memo)も効果的に機能するようになります。
第2章: useEffect - 副作用との正しい付き合い方
Reactコンポーネントの主な仕事は、状態とpropsに基づいてUIを描画することです。しかし、実際のアプリケーションでは、コンポーネントの描画プロセスとは直接関係のない、さまざまな「副作用(Side Effects)」を扱う必要があります。例えば、以下のような処理です。
- データフェッチング:APIからデータを取得する
- DOMの直接操作:Reactの管理外のDOM要素を変更する(例:フォーカス設定、ライブラリ連携)
- サブスクリプション:タイマー(
setInterval)の設定、WebSocketへの接続、イベントリスナー(window.addEventListener)の登録
これらの処理をコンポーネントのレンダリングロジックの中に直接書くことはできません。なぜなら、レンダリングは複数回発生する可能性があり、そのたびに副作用が実行されてしまうと、意図しない結果(例:APIの過剰な呼び出し)を招くからです。useEffectは、こうした副作用をReactのレンダリングサイクルと同期させるためのフックです。
2.1 useEffectの基本構造と哲学
useEffectは、従来のクラスコンポーネントにおけるcomponentDidMount、componentDidUpdate、componentWillUnmountの組み合わせと考えることができますが、その考え方は少し異なります。useEffectはライフサイクルではなく、「同期」という観点で捉えるのがより正確です。
「このコンポーネントの特定のデータが変更されたら、外部のシステム(DOM、API、タイマーなど)をそのデータと同期させてください」とReactに指示するのがuseEffectの役割です。
基本的な構文は以下の通りです。
import { useEffect } from 'react';
useEffect(() => {
// ここに副作用のコードを記述する
// (例: APIコール、DOM操作など)
return () => {
// ここはクリーンアップ関数(オプション)
// 副作用を「お掃除」するコードを記述する
};
}, [/* 依存配列 */]);
- 第1引数(エフェクト関数): 副作用を実行するロジックを含む関数です。この関数は、コンポーネントがDOMに描画された「後」に実行されます。これにより、副作用がUIのブロッキングを防ぎます。
- 第2引数(依存配列 - Dependency Array): エフェクトをいつ実行するかをReactに伝えるための配列です。この配列の要素が、前回のレンダリング時と比較して変更された場合にのみ、エフェクト関数が再実行されます。この依存配列の理解が、
useEffectを使いこなす上で最も重要な鍵となります。
2.2 依存配列の徹底解剖:3つのパターンを理解する
依存配列の指定方法によって、useEffectの振る舞いは大きく変わります。この3つのパターンを正確に理解しましょう。
パターン1:依存配列を省略する
useEffect(() => {
console.log('Component rendered or updated');
// このエフェクトは、毎回のレンダリング後に実行される
});
第2引数を完全に省略すると、エフェクトはコンポーネントの初回レンダリング後、および毎回の再レンダリング後に実行されます。これは通常、望ましい動作ではありません。例えば、この中で状態を更新するような処理を書くと、状態更新 → 再レンダリング → エフェクト実行 → 状態更新 → ...という無限ループに陥りやすくなります。このパターンが必要になるケースは非常に稀であり、ほとんどの場合はバグの原因となります。
パターン2:空の依存配列 [] を指定する
useEffect(() => {
console.log('Component mounted');
// このエフェクトは、初回のレンダリング後に一度だけ実行される
// (厳密には、一度だけ実行が保証される)
}, []);
空の配列[]を渡すと、エフェクトは初回のレンダリング後に一度だけ実行されます。その後の再レンダリングでは、依存配列の中身(何もない)が変化しないため、エフェクトは再実行されません。これは、クラスコンポーネントのcomponentDidMountに最も近い振る舞いです。以下のような初期化処理に適しています。
- 初期データを一度だけAPIから取得する
- グローバルなイベントリスナー(例:
windowへのイベント)を登録する - タイマーを開始する
パターン3:依存配列に値を指定する
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
console.log(`Fetching data for user ${userId}`);
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // userIdが変更されたときだけ、このエフェクトを再実行する
if (!user) {
return Loading...;
}
return {user.name};
}
依存配列に変数(propsやstate)を指定すると、エフェクトは初回のレンダリング後、および指定した変数のいずれかの値が前回のレンダリング時から変更された場合に再実行されます。これは、クラスコンポーネントのcomponentDidUpdateのロジックに相当します。上の例では、UserProfileコンポーネントに渡されるuserId propsが変更されるたびに、新しいユーザーのデータをフェッチし直します。もしuserIdが変更されなければ、コンポーネントが他の理由(例:親コンポーネントの再レンダリング)で再レンダリングされても、APIコールは発生しません。これにより、不要なネットワークリクエストを防ぎ、パフォーマンスを向上させることができます。
依存配列のルール: エフェクト内で使用するすべてのリアクティブな値(props, state, およびそれらから派生した値)は、依存配列に含めるべきです。Reactの公式ESLintプラグイン(eslint-plugin-react-hooks)は、このルールを強制し、依存関係の漏れを警告してくれるため、導入は必須と言えます。
2.3 クリーンアップ関数:副作用の「後始末」
副作用の中には、設定したら「やりっぱなし」ではいけないものがあります。例えば、setIntervalでタイマーを開始したり、window.addEventListenerでイベントリスナーを登録したりした場合、コンポーネントが画面から消える(アンマウントされる)ときに、それらを解除しないとメモリリークや意図しないバグの原因となります。
useEffectは、エフェクト関数から別の関数を返すことで、この「後始末」のロジックを記述する仕組みを提供します。この返される関数を「クリーンアップ関数」と呼びます。
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// 副作用: 1秒ごとにstateを更新するタイマーを設定
const intervalId = setInterval(() => {
setSeconds(prevSeconds => prevSeconds + 1);
}, 1000);
console.log('Timer started with ID:', intervalId);
// クリーンアップ関数
return () => {
console.log('Cleaning up timer with ID:', intervalId);
clearInterval(intervalId); // タイマーを解除
};
}, []); // 空の配列なので、マウント時に設定、アンマウント時にクリーンアップ
return Timer: {seconds}s;
}
クリーンアップ関数が実行されるタイミングは2つあります。
- コンポーネントがアンマウントされる時: コンポーネントがDOMツリーから削除される直前に、最後のクリーンアップが実行されます。これにより、コンポーネントが消えた後もバックグラウンドで不要な処理が動き続けるのを防ぎます。
- エフェクトが再実行される直前: 依存配列に値が指定されている場合、その値が変更されてエフェクトが再実行される際には、まず前回のエフェクトのクリーンアップ関数が実行され、その後に新しいエフェクト関数が実行されます。これにより、古い副作用(例:古いイベントリスナー)が残ることなく、常に最新の状態に基づいた副作用のみがアクティブになります。
このクリーンアップの仕組みは、関連する「設定」と「後始末」のロジックを同じ場所に記述できるため、コードの見通しを良くし、バグを減らすのに非常に効果的です。
第3章: 実践的パターン - useStateとuseEffectの連携
useStateとuseEffectの基本を理解したところで、これらを組み合わせて実際のアプリケーションでよく見られるパターンを構築してみましょう。この連携こそが、動的なReactアプリケーションの心臓部となります。
3.1 データフェッチングの完全な実装
APIからデータを取得する処理は、最も一般的な副作用の一つです。単にデータを取得するだけでなく、ローディング状態やエラー状態も管理することで、より良いユーザー体験を提供できます。
import React, { useState, useEffect } from 'react';
function PostViewer({ postId }) {
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// AbortControllerを使って、コンポーネントのアンマウント時に
// フェッチリクエストをキャンセルする準備
const controller = new AbortController();
const signal = controller.signal;
const fetchPost = async () => {
// 新しいpostIdでフェッチが始まるたびに状態をリセット
setLoading(true);
setError(null);
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setPost(data);
} catch (err) {
// AbortErrorはユーザー起因のキャンセルなので、エラーとして扱わない
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchPost();
// クリーンアップ関数
return () => {
// コンポーネントがアンマウントされるか、postIdが変更された場合、
// 進行中のフェッチを中止する
controller.abort();
};
}, [postId]); // postIdが変更されたら、エフェクトを再実行
if (loading) {
return Loading post...;
}
if (error) {
return Error: {error};
}
if (!post) {
return null;
}
return (
{post.title}
{post.body}
);
}
このコンポーネントには、堅牢なデータフェッチングのための多くの要素が含まれています。
- 3つの状態変数:
post(データ本体)、loading(読み込み中か)、error(エラーメッセージ)をそれぞれuseStateで管理します。これにより、UIは現在のフェッチングの状況に応じて3つの異なる表示(ローディング、エラー、成功)を切り替えることができます。 - 依存配列
[postId]:useEffectはpostIdに依存しているため、親コンポーネントから渡されるpostIdが変わるたびに、新しい投稿を取得し直します。 - クリーンアップと
AbortController: ユーザーが素早く投稿を切り替えた場合、前の投稿のフェッチリクエストが完了する前に新しいリクエストが開始される可能性があります。もし古いリクエストが後から完了すると、古いデータで状態を上書きしてしまい、UIに一瞬古い内容が表示されるという競合状態が発生します。AbortControllerを使い、クリーンアップ関数でcontroller.abort()を呼び出すことで、進行中のリクエストをキャンセルし、このような問題を未然に防ぎます。これは、アンマウント時の不要なstate更新エラーを防ぐ上でも重要です。
3.2 イベントリスナーの管理
ブラウザのwindowやdocumentオブジェクトにイベントリスナーを追加する際も、useEffectは非常に役立ちます。コンポーネントのマウント時にリスナーを登録し、アンマウント時に必ず解除することが重要です。
例えば、ウィンドウの幅を追跡するコンポーネントを考えてみましょう。
import React, { useState, useEffect } from 'react';
function WindowSizeReporter() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
// コンポーネントのマウント時にイベントリスナーを登録
window.addEventListener('resize', handleResize);
console.log('resize listener added');
// クリーンアップ関数でイベントリスナーを解除
return () => {
window.removeEventListener('resize', handleResize);
console.log('resize listener removed');
};
}, []); // 空の配列なので、マウントとアンマウント時にのみ実行
return Window width: {width}px;
}
このパターンにより、WindowSizeReporterコンポーネントが表示されている間だけresizeイベントを監視し、表示されなくなったらリソースを解放するため、アプリケーション全体に影響を与えることなく、安全にブラウザAPIを利用できます。
第4章: 高度なトピックとベストプラクティス
useStateとuseEffectを使いこなすためには、いくつかのルールと、より高度な概念を理解しておくことが役立ちます。
4.1 フックのルール
Reactのフックには、厳守しなければならない2つのルールがあります。これらのルールは、フックが正しく動作するための前提条件です。
- フックはトップレベルでのみ呼び出すこと。 ループ、条件分岐、ネストされた関数の中でフックを呼び出してはいけません。これは、Reactがフックの状態を管理するために「呼び出し順序」に依存しているためです。毎回同じ順序でフックが呼び出されることで、Reactはどの状態がどの
useState呼び出しに対応するかを正確に把握できます。 - フックはReactの関数コンポーネント、またはカスタムフックからのみ呼び出すこと。 通常のJavaScript関数からフックを呼び出すことはできません。
これらのルールは、前述のeslint-plugin-react-hooksを導入することで、コーディング中に自動的にチェックできます。
4.2 カスタムフック:ロジックの再利用
コンポーネント間で状態管理のロジックを再利用したい場合、カスタムフックを作成するのが最も優れた方法です。カスタムフックとは、useで始まる名前を持ち、内部で他のフック(useStateやuseEffectなど)を呼び出すことができる、ただのJavaScript関数です。
例えば、先ほどの「ウィンドウ幅を追跡するロジック」は、他のコンポーネントでも使いたいかもしれません。これをカスタムフックuseWindowSizeとして切り出してみましょう。
import { useState, useEffect } from 'react';
// カスタムフック: useWindowSize
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // マウント・アンマウント時にのみ実行
return size;
}
// カスタムフックを使用するコンポーネント
function MyComponent() {
const { width, height } = useWindowSize();
return (
Window width: {width}px
Window height: {height}px
);
}
function AnotherComponent() {
const { width } = useWindowSize();
return (
The current window width is {width}.
);
}
useWindowSizeフックは、ウィンドウサイズのロジック(useStateとuseEffect)を完全にカプセル化しています。これを使用するコンポーネント(MyComponentやAnotherComponent)は、イベントリスナーの登録や解除といった実装の詳細を気にする必要がありません。ただuseWindowSize()を呼び出すだけで、現在のウィンドウサイズをリアクティブな値として受け取ることができます。これがフックによるロジックの再利用の強力さです。
同様に、データフェッチングのロジックもuseFetchのようなカスタムフックに切り出すことができます。これにより、コンポーネントはUIの表示に集中でき、データ取得の複雑なロジックは再利用可能なフックに委ねることができます。
まとめ
useStateとuseEffectは、モダンなReact開発の基盤となる2つの重要なフックです。useStateは関数コンポーネントに「記憶」を与え、UIが状態の変更に追従できるようにします。その際には、関数型更新や不変性の原則を守ることが、予測可能で堅牢なコードにつながります。一方、useEffectはコンポーネントの世界と外部の世界を「同期」させるための強力なツールです。その動作を正確に制御する鍵は依存配列にあり、設定した副作用の後始末を行うクリーンアップ関数は、メモリリークやバグを防ぐために不可欠です。
これらのフックを深く理解し、それらを組み合わせてカスタムフックを作成することで、クリーンで、再利用性が高く、メンテナンスしやすいReactアプリケーションを構築することができます。フックは単なるAPIではなく、Reactの宣言的な性質をより純粋な形で表現するためのパラダイムシフトです。この基本をマスターすることが、より高度なReactの機能(useContext, useReducer, useCallbackなど)を学び、複雑なアプリケーションを構築するための確かな土台となるでしょう。
0 개의 댓글:
Post a Comment