Posts

Reactの純粋性と副作用を、公式ドキュメントより噛み砕いて整理する

  • #React
  • #JavaScript

はじめに

最近、ReactやVueの公式ドキュメントを読んで、その原理原則を深く追求することにちょっぴりハマっています。今日は、そんな中から、Reactの根幹である純粋性と副作用について考えました。公式ドキュメントの説明や例って、びみょ〜に分かりづらいんですよね…今回はそれを、少しだけ自分ごとになるように言語化してみました。

1. 純粋関数とは

まずReact抜きで、ただのJavaScriptの話です。

純粋関数の条件は2つだけです。

  1. 同じ入力なら必ず同じ出力
  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(外部と同期する時だけ)

判断に迷ったらこの順で考えることにしています。

  1. これは計算か? → コンポーネント本体に普通に書く
  2. これはユーザーの操作の結果か? → イベントハンドラ
  3. これは表示されたこと自体が引き金か? → useEffect

大半は1か2で片付きます。useEffectを書きたくなった時は「本当に3か?」と一度疑うのがコツです。AIにコードを書かせると、とりあえずuseEffectを多用したがりますが、本当に必要かを考えると、大抵消せます。

純粋性や副作用に関する議論は、Reactを使う限り避けては通れません。ちゃんと理解しようとすると、なかなか奥が深い概念だと思い知らされました。