🧪

Reactの文脈での「副作用」は2種類あるのではという話

に公開
4

皆さんこんにちは。Reactの話をする際に、副作用という言葉はよく出てきます。しかし、実は、我々は2つの異なる意味で「副作用」という言葉を使っており、そのせいで混乱が生じているのではないかと思います。

例えば、筆者が最近書いた以下の記事では、基本的にuseEffectの中でfetchするべきではないと説明しました。

https://qiita.com/uhyo/items/dec319ced85fc1b83f86

このような話題に対しては、「useEffectの中でfetchして何が悪い」のような批判が寄せられることがあります[1]。つまり、useEffectは副作用を記述するフックで、fetchはネットワークリクエストという副作用を起こすのだから、useEffectの中でfetchするのは適切だろう、という意見です。

筆者の意見では、これは2種類の「副作用」を混同したことによる誤解です。

要するに、以下の2つの文で言われている「副作用」は、(まったく無関係ではないにせよ)実は少し異なる、別々の概念を指しているのだということです。

  • useEffectはコンポーネントの副作用を記述するためのフックである。
  • Reactではコンポーネントは純粋に保つべきであり、コンポーネント内に直接副作用を記述してはいけない。

useEffectの“副作用”

まず、1つ目の “副作用” について考察しましょう。

useEffectはコンポーネントの副作用を記述するためのフックである。

これは、前述のQiita記事でも説明した概念です。まず、コンポーネントの作用とは何か考えましょう。ここでは、そのコンポーネントがレンダリングされると何が起こるのかを指して作用と呼んでいます。

コンポーネントの主たる作用は、コンポーネントがレンダリングされた位置にコンポーネントの内容(所定のDOM要素)を表示することです。コンポーネントはJSXを返すことで自身の作用を宣言し[2]、Reactランタイムがその作用を実際に行います。

しかし、コンポーネントによっては、DOMに表示すること以外にもレンダリングされたことによる影響(作用)を持つことがあります。前述のQiita記事では副作用という言葉を避けて「コンポーネントが表示されていることの追加の作用」と説明していますが、主たる作用以外に起こることだと考えれば、副作用と呼ぶのもあながち間違いではなさそうです。

この意味での「コンポーネントの副作用」の例としては、コンポーネントの管轄外のDOM(画面全体など)に対してイベントハンドラを登録することが挙げられます。

useEffect(() => {
  const controller = new AbortController();
  document.addEventListener("scroll", () => {
    // 何かする 
  }, {
    passive: true,
    signal: controller.signal,
  });

  return () => {
    controller.abort();
  };
}, []);

そして、コンポーネントの副作用に関して注意すべきことは、あくまでコンポーネントの作用の一部であることです。つまり、副作用であったとしても、コンポーネントの原則から逸脱すべきではないということです。

ここでいうコンポーネントの原則とは、主に以下の2つのことを指します。

  1. コンポーネントの作用は、コンポーネントがマウントされている間だけ有効である。つまり、コンポーネントがアンマウントされると副作用も消えるということです。上の例は、コンポーネントがアンマウントされる際にイベントハンドラが取り除かれるので、この原則に従っています。
  2. コンポーネントの作用は、コンポーネントのpropsやステートを入力とする純粋な計算により決まる。つまり、主作用である「コンポーネントの表示内容」がコンポーネントのpropsやステートに基づいて決まらなければならないのはもちろんのこと、コンポーネントの副作用にも同じことが当てはまるということです。すなわち、副作用の内容もコンポーネントのpropsやステートに基づいて決まる必要があるということです。

useEffectで定義される“副作用”はまったくの無法ではなく、あくまでコンポーネントの作用の一部を定義するために用いられるAPIであるため、これらのルールに従って実装される必要があります。

useEffectは、ライフサイクルを持つエフェクトとして説明されることもあります。これも、useEffectの副作用がコンポーネントの作用の一部であるという考え方と合致しています。コンポーネントのpropsやステートが変わればコンポーネントの内容が変化する(主たる作用が変化する)ことがあるのと同様に、useEffectの副作用もpropsやステートに応じて変化することがあります。その変化を正しく実装するために、エフェクトは発火やクリーンアップを行う必要があるのです。この考え方についてさらに知るには、以下の記事がおすすめです。

https://zenn.dev/yumemi_inc/articles/react-effect-simply-explained

プログラムの一般的な概念としての副作用

2つ目の “副作用” については、Reactに特有の概念ではなく、プログラム全般における用語としての副作用を指しています。

Reactではコンポーネントは純粋に保つべきであり、コンポーネント内に直接副作用を記述してはいけない。

簡単に言えば、副作用を持つ関数は純粋関数ではありません。逆に言えば、関数を純粋ではなくするものが副作用だということです。具体的には、副作用は、関数の結果として返り値が得られること以外に外部に何らかの影響を起こすこと全般を指します。ネットワークリクエストが行われるというのも、この意味での代表的な副作用です。

Reactのルールや設計論においては「純粋関数」という用語がよく出てきます。関数コンポーネントは(フックを呼び出している点で特殊ですが[3])純粋関数であるべきとされています。また、useStateのステート更新を関数で行うときなども、その関数は純粋関数であるべきとされています。

こちらについては、Reactの文脈でもよく出てくるものの、Reactに特有の概念ではありませんから、この記事では詳しく説明しません。

2つの副作用の関係

ここでは、「useEffectの副作用」を副作用①とし、「純粋関数ではないという意味での副作用」を副作用②と呼ぶことにしましょう。

副作用①と副作用②は、似て非なる概念ですが、無関係ではありません。冒頭でリンクしていたReactの公式ドキュメントには、以下のような記述があります。

https://ja.react.dev/learn/keeping-components-pure#where-you-can-cause-side-effects

関数型プログラミングには純粋性が重要であるとはいえ、いつか、どこかの場所で、何らかのものが変化しなければなりません。むしろそれがプログラミングをする意味というものでしょう。これらの変化(スクリーンの更新、アニメーションの開始、データの変更など)は 副作用 (side effect) と呼ばれます。レンダーの最中には発生しない、「付随的」なものです。

React では、副作用は通常、イベントハンドラの中に属します。(中略)

いろいろ探してもあなたの副作用を書くのに適切なイベントハンドラがどうしても見つからない場合は、コンポーネントから返された JSX に useEffect 呼び出しを付加することで副作用を付随させることも可能です。これにより React に、その関数をレンダーの後(その時点なら副作用が許されます)で呼ぶように指示できます。ただしこれは最終手段であるべきです。

これによると、副作用をuseEffectで記述するやり方を、最終手段として紹介しているように見えます。

この記事の用語で言えば、この記述は副作用②を発生させる手段としてuseEffectを使用することを意味しています。つまり、本来副作用①のための道具であるuseEffectを、副作用②のために(最終手段として)使うこともできてしまうのです。この点で、2つの副作用は無関係ではなさそうです(それと同時に、この記事の冒頭で指摘した混同の原因でもあるでしょう)。

ただし、これが最終手段とされていることからも分かるように、常用すべきやり方ではありません。「レンダリング時に何か副作用②を発生させるためにuseEffectを使う」場合、それはuseEffectの正しい使い方ではないことがほとんどでしょう。なぜなら、その副作用②の効果がコンポーネントのライフサイクルに一致するものだとは考えられないからです。正しい使い方というのは、上述のように、コンポーネントの責務の一部としての副作用①を実装するために使うことです。

まとめ

「useEffectは副作用のためのフックだ」という場合、その副作用というのは、副作用①(「コンポーネントの副作用」)のことであり、副作用②のことではないというのが筆者の考えです。

useEffectの中ならどんな副作用②でも起こしていいというのは、2種類の副作用を同一視したことにより生じる誤解ではないでしょうか。useEffectはあくまで副作用①のためのフックであり、副作用②のためにuseEffectを使うのは極力避け、どうしても他に手が無いときの最終手段であるべきです。

この記事が皆さんの考えを整理する手助けになれば幸いです。

余談

この記事ではuseEffectを「副作用」と呼ぶ立場に立ち、その結果副作用の概念が2種類に分かれることを説明しました。しかし、そもそもuseEffectのことを副作用と呼ばなければこんな混乱は起きないとも思っています。そのため、実は筆者としては、useEffectを「副作用」と呼ぶのはあまり好きではありません。本文で説明したように、useEffectのことを副作用と呼ぶことも間違いではないとは思いつつ、副作用と呼ばないほうが分かりやすいのではないかと考えています。

そもそも、useEffectという名前にあるeffectとは単に「作用」という意味であり、副作用 (side effect) とは言われていませんよね。useEffectで書かれたロジックもコンポーネントの作用の一部であり、useEffectはコンポーネントの作用を定義するためのAPIであると考え、筆者としてはuseEffectに書かれたロジックは単に「エフェクト」とか呼ぶことを好んでいます。

本文と余談で言っていることが違うじゃないかと思われるかもしれませんが、この記事はuseEffectを「副作用」と呼ぶ人が多くいることを踏まえて、その立場の方に伝わりやすいように、その立場に立って書いています。余談では筆者の本来のスタンスを説明しました。

脚注
  1. 上述の記事でも、なぜか消えてしまいましたが、このような文言のコメントがはてブに付いているのを見かけました。 ↩︎

  2. JSXは構文のことなので、正確に言えばJSX式の値を返しています。TypeScriptの型で言えばReactElementです。 ↩︎

  3. フックの返り値も関数への入力だと考えれば、純粋関数の考え方を当てはめることも不可能ではないでしょう。フックへの入力に関数内での計算が使われるという点で、一般的な関数の計算モデルと乖離しているのは変わりませんが。 ↩︎

GitHubで編集を提案

Discussion

あいや - aiya000あいや - aiya000

本来副作用①のための道具であるuseEffectを、副作用②のために(最終手段として)使うこともできてしまうのです

なるほど。コンポーネント外の真の副作用①である

eff :: A -> IO B

があったときに

somePureComponent :: MonadState State m => B -> m (FC X)

があって、その内部で

-- `MonadReader B m`は「useEffectはBを含むクロージャーにできるよ」の気持ち
useEffect :: (MonadState State m, MonadReader B m) => m ()

が使われるべきなのに、実際は

useEffect :: IO () -- これが②

になっている(※JavaScriptなのでB -> m (FC X)内でIO ()が呼べる)
という話か〜

NakamuraNakamura

何が①の副作用で、何が②の副作用になるのかは、これまた論争になりそうですね

FF

長年のモヤモヤがスッキリしました...!

Reactではコンポーネントは純粋に保つべきであり、コンポーネント内に直接副作用を記述してはいけない。

ただこの箇所について少しひっかかりました。
私の認識としてはReactはコンポーネントの"render処理"は純粋に保つべきであり、コンポーネント全体では
「UI = f(state)」以外の処理も担当するのでコンポーネント全体では純粋ではない気がしました、、、😵‍💫

rustsambrustsamb

記事で提示された「描画時副作用」と「イベント副作用」という分類は非常に有用ですが、別の視点から「Reactの管理下/管理外」という観点で考えることもできると思います。

useState/useReducerなどによる状態更新は完全にReactの管理下ですが、onClickなどのイベント処理、useRef.current操作、useEffect内のコールバックはReactの管理下と管理外の「境界領域」と言えます。特にuseEffectはタイミングはReactが制御しますが、その中身はReact外の世界との相互作用になります。

React管理外の操作(DOMの直接操作、外部APIコール等)が増えると、デバッグが追いづらくなり、テストも複雑になります。これが「副作用」という表現がふさわしいと支持される所以であり、記事の言う「描画時副作用」はReact管理下との境界、「イベント副作用」はユーザーイベントとReact管理下との境界と捉えることができます。