はじめに
最近、ReactやVueの公式ドキュメントを読んで、その原理原則を深く追求することにちょっぴりハマっています。今日は、そんな中から、Reactの根幹である純粋性と副作用について考えました。公式ドキュメントの説明や例って、びみょ〜に分かりづらいんですよね…今回はそれを、少しだけ自分ごとになるように言語化してみました。
1. 純粋関数とは
まずReact抜きで、ただのJavaScriptの話です。
純粋関数の条件は2つだけです。
- 同じ入力なら必ず同じ出力
- 呼んでも外の世界を変えない
// 純粋
function double(n) {
return n * 2;
}
double(3); // 6
double(3); // 6 ← 何回呼んでも同じ!
数学の世界では、これは当たり前のことです。そもそも純粋でない関数というのは定義上存在しません。でも、プログラミングでは、コードの書き方次第では不純な関数が生まれてしまいます。
たとえば、以下は不純な例です。
let total = 0;
// 不純: 関数外の total を書き換えている!
function addToTotal(n) {
total += n;
return total;
}
addToTotal(3); // 3
addToTotal(3); // 6 ← 同じ入力なのに結果が違う!
total という「関数の外にあるもの」を触ったせいで、呼ぶ順番や回数で結果が変わるようになりました。
2. Reactコンポーネントも関数
Reactのコンポーネントは「props を受け取って JSX を返す関数」です。だから同じルールが当てはまります。
// 純粋: 同じ props なら必ず同じ JSX が返る
function Greeting({ name }) {
return <p>こんにちは、{name}さん!</p>;
}
以下は、不純な典型例です。
let count = 0;
// 不純: コンポーネントの外側の変数を書き換えている
function Counter() {
count = count + 1;
return <p>{count}回目のレンダリング</p>;
}
<Counter /> を2つ並べると「1回目」「2回目」と表示されます。同じJSXを書いているのに違うものが出ます。これがバグの温床になります。
もっとよく見るのはこれです。
// 不純: 受け取った配列そのものを書き換えている
function SortedList({ items }) {
items.sort(); // ← 親が持っている配列を破壊している!
return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
}
// 純粋: コピーしてから並べ替える
function SortedList({ items }) {
const sorted = [...items].sort();
return <ul>{sorted.map(i => <li key={i}>{i}</li>)}</ul>;
}
sort() は元の配列を破壊するメソッドです。propsは読み取り専用です。
なお、レンダリング中に作った変数を書き換えるのはセーフです。
// 純粋: cells はこのレンダリングの中で生まれて完結している
function Table({ rows }) {
const cells = [];
for (const row of rows) {
cells.push(<td key={row.id}>{row.name}</td>);
}
return <tr>{cells}</tr>;
}
境界は「この呼び出しの中で生まれたものか、外から来たものか」です。
3. 副作用とは
React において、副作用は常に付き纏ってくる厄介な問題です。副作用とは、 JSXを返す以外に、外の世界に起こしてしまう変化のことです。
function Profile({ userId }) {
fetch(`/api/users/${userId}`); // 外部と通信する
localStorage.setItem('last', userId); // ストレージに書く
document.title = 'プロフィール'; // DOM を直接いじる
console.log('rendered'); // ログを出す
return <div>...</div>;
}
全部「UI の計算」以外の処理です。これらをレンダリング中に書くと壊れます。
理由は、Reactがいつ・何回コンポーネント関数を呼ぶか保証していないからです。開発モード(StrictMode)ではわざと2回呼ぶし、本番でも再レンダリングは頻繁に起きます。上のコードだと fetch が意図せず2回、10回と飛ぶことになります。
4. 副作用をどこに書くか
答えは2箇所です。基本的には、イベントハンドラでいいのです。
パターンA: イベントハンドラ
例えば、ユーザーの操作がきっかけなら、こうです。
function SaveButton({ text }) {
// レンダリング中には何も起きない。クリックされた「その時」だけ動く
const handleClick = async () => {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ text }),
});
};
return <button onClick={handleClick}>保存</button>;
}
handleClick の中身はレンダリング中には実行されません。だから何を書いてもコンポーネントの純粋性は壊れません。
パターンB: useEffect(画面に出たこと自体がきっかけの時だけ)
「表示されたら購読する」「表示されたらタイトルを変える」のような、操作ではなく同期が目的の時だけです。
function DocumentTitle({ title }) {
useEffect(() => {
document.title = title;
}, [title]);
return null;
}
useEffect は**「レンダリングが終わって画面に反映されたあと」**に走ります。だからレンダリング関数自体は純粋なまま保てます。
5. なぜReactはこれを要求するのか
純粋であることを守ると、Reactは 「このコンポーネントはpropsが変わっていないから再計算をスキップしてよい」「途中まで計算したが中断して後でやり直そう」 といった最適化ができます。副作用が混ざっていると、スキップしたり中断したりした瞬間に挙動が壊れます。
StrictModeがわざと2回呼ぶのは、この違反を開発中に気づかせるためです。「なぜか2回fetchが飛んでいる」となったら、それはたいていレンダリング中に副作用を書いているサインです。
まとめ
| 書き場所 | いつ動くか | 副作用を書いてよいか |
|---|---|---|
| コンポーネント本体 | レンダリングのたび(回数不定) | ダメ |
| イベントハンドラ | ユーザー操作の時だけ | OK(第一選択) |
| useEffect | 画面反映後 | OK(外部と同期する時だけ) |
判断に迷ったらこの順で考えることにしています。
- これは計算か? → コンポーネント本体に普通に書く
- これはユーザーの操作の結果か? → イベントハンドラ
- これは表示されたこと自体が引き金か? → useEffect
大半は1か2で片付きます。useEffectを書きたくなった時は「本当に3か?」と一度疑うのがコツです。AIにコードを書かせると、とりあえずuseEffectを多用したがりますが、本当に必要かを考えると、大抵消せます。
純粋性や副作用に関する議論は、Reactを使う限り避けては通れません。ちゃんと理解しようとすると、なかなか奥が深い概念だと思い知らされました。