💊

Webフロントエンドにおける副作用について考える

2022/01/10に公開

個人的にHaskellを学び始めてから、世の中のソフトウェアは不純なもの(副作用)で溢れていることに気づいたので、Webフロントエンドに当てはめて考えたことをまとめてみます。

主にReactやVue等でのSPAを想定して執筆しています。
(が、副作用の考え方自体は抽象度の高い概念なので、バックエンドやモバイルアプリ等についても応用できるところはあるかもしれません。)

副作用とは

Wikipediaには以下のようにあります。
副作用 (プログラム)

プログラミングにおいて、式の評価による作用には、主たる作用とそれ以外の副作用(side effect)とがある。 式は、評価値を得ること(※関数では「引数を受け取り値を返す」と表現する)が主たる作用とされ、それ以外のコンピュータの論理的状態(ローカル環境以外の状態変数の値)を変化させる作用を副作用という。

この説明だけでは理解が難しそうな気がしますが、キーワードは太線にしている
ローカル環境以外の状態変数の値を変化させるという部分だと考えます。

簡単な例を見ていきます。
まずは副作用を伴わない関数の例です。

nonSideEffect.ts
function addTwo(x: number) {
    const result = x + 2;
    return result;
}
// 実行
addTwo(1); // 3
addTwo(1); // 3
addTwo(1); // 3

何回呼び出しをしても同じ結果が返ってきます。

次に副作用を伴う関数の例です。

sideEffect.ts
let result = 2; // 副作用
function addTwo(x: number) {
    result += x;
    return result;
}

addTwo(1); // 3
addTwo(1); // 4
addTwo(1); // 5

同じ引数で同じ関数を呼び出しているにも関わらず、呼び出す度に異なる結果が返ってきます。
これはaddTwo関数がaddTwo関数外の部分に影響を与えているためです。
これが先の引用の中にある「ローカル環境以外の状態変数の値を変化させる」という部分に該当します。

ちなみに副作用の説明についてはオブジェクト指向並に様々な説明が世間には溢れているので
他の記事も参考にしてみてください。

参考

副作用ってなんだ? 〜楽に小さく単体テストをしよう〜
プログラミングで副作用と状態ってなに?

副作用を排除することは可能か

少なくともアプリケーションを作成する上ではほぼ不可能だと考えます。
なぜなら世の中のアプリケーションは副作用を伴うもので溢れているからです。

先の例では簡単なプログラム内に留まった例を示しましたが、
アプリケーションにおいての副作用の代表的な例はデータベースなどです。

単純なSELECT文を発行する場合も、実行結果はSELECT対象のテーブルの状態に依存します。
テーブルの状態はプログラムから見ると実際にアクセスしてみるまでわかりません。

SELECT * FROM users; // usersテーブルの状態に依存する

先ほど「ローカル環境以外の状態変数の値を変化させる」とあったので、プログラム内に留まる話のように聞こえたかもしれませんが、実際にはデータベースを始めとしたプログラムの外に出た状態の話も含むことが多いです。ここでいうローカルという部分が文脈によってスコープが変わることがある気がしています。

フロントエンドにおける副作用の例

フロントエンドから直接データベースにアクセスすることはあまりないと思うので、また違った視点からの副作用の例としてZennの画面を見てみましょう。

以下に2枚のキャプチャを貼ります。
両方ともトップページにアクセスしたときのキャプチャです。

  • 認証した状態でアクセス
    zenn.png

  • 認証なしでアクセス(シークレットモードでアクセス)
    zenn2.png

同じURL(画面)にアクセスしているにも関わらず、ブラウザの状態によってヘッダー部分に差分が出ているのが確認できます。

フロントエンドにおいて副作用を伴いがちなもの

代表的なものをいくつか列挙します。他にもいっぱいあるかもしれません。

バックエンドAPI

SPA等では基本的にバックエンドAPIをコールし、その結果を表示するような挙動が多いかと思います。

このときに受け取るバックエンドAPIの結果は言わずもがなバックエンドやその先にあるDBの状態などに依存します。
フロントエンドからすると実際にコールしてみないと結果がわからないです。

// API・DB等の状態に依存する。
// データが100件返ってくることもあれば0件の場合もある。エラーのときもある。
const users = await fetch('https://xxxxxx.com/users'); 

Web Storage / IndexedDB / Cookieなど

ブラウザ上に保存されるデータ全般です。
例えばLocalStorageを扱う例だと以下のようになります。

// localStorage.setItem('data', 'this is sideEffect.')
const data = localStorage.getItem('data');

localStorageにデータがセットされていればセットされているデータが返りますが、そうでない場合はnullが返ってきます。
同じ引数で同じAPIをコールしているのにも関わらず結果が変わってきます。

この例もフロントエンドのアプリケーションからすると、実際にgetItemしてみるまでどういったデータが格納されているかわかりません。

Global State

VueにおけるVuex StoreやReactにおけるRedux StoreやContextなど、グローバルにアクセス可能なStateも副作用を伴います。
先の例で示した認証状態もこのようなGlobal Stateに持たせることが多いと思います。

簡単な例としてVuex公式のコードを見てみます。

const Counter = {
  template: `<div>{{ count }}</div>`,
  computed: {
    count () {
      return store.state.count
    }
  }
}

このCounterというコンポーネントはstore.state.countという値を表示するコンポーネントとなっており、storeに依存する形になっています。
このstoreはグローバル変数のようなイメージで、Counter以外の他のコンポーネントからも参照することができる状態になっています。当然別のコンポーネントがこの値を更新した場合はその影響を受けてしまいます。

以前はとりあえずStoreにデータをセットすることによって、コンポーネント間のバケツリレーを防いだりするような設計もありましたが、近年はReact HooksやVue Composition API等が登場したこともあり、複数画面に跨るようなデータ以外でStoreを濫用するのはあまりよろしくない印象を持っています。
用法容量を守って適切に使いましょう。

Routing関連

Routing周りも副作用を伴います。
とりわけSPAはブラウザのHistoryを書き換えるという性質上、プログラム上だけでなくブラウザが持つ状態に依存する部分が多くなります。

例えばReact RouterでHistoryを扱う例を引用します。

引用元

// This is a React Router v6 app
import { useNavigate } from "react-router-dom";

function App() {
  const navigate = useNavigate();

  return (
    <>
      <button onClick={() => navigate(-2)}>
        Go 2 pages back
      </button>
      <button onClick={() => navigate(-1)}>Go back</button>
      <button onClick={() => navigate(1)}>
        Go forward
      </button>
      <button onClick={() => navigate(2)}>
        Go 2 pages forward
      </button>
    </>
  );
}

上記はReact Router v6のものです。
v5ではuseHistoryからのgoというAPIでしたが、v6ではuseNavigate、navigateと変更されています。

こちらもボタン押下時にnavigate(1)等が実行されますが、実行した結果はブラウザが保持しているHistoryの状態に依存します。

また、URL Parameterを使う場合も副作用を伴います。
こちらも公式の例を見てみましょう。

import { useParams } from "react-router-dom";

export default function Invoice() {
  let params = useParams();
  return <h2>Invoice: {params.invoiceId}</h2>;
}

当然ここのuseParams()の結果はブラウザのアドレスバーの値によって変わってしまいます。
また、よくある構成だとuseParams()で取得したIDを元にバックエンドAPIを叩いたりすることもあるので、連続的に副作用を伴うこともあります。

最後に

フロントエンドにおける副作用について色々書いてきましたが、大事なことは副作用が伴う部分を理解した上で、これらをどう取り扱っていくのかということだと思います。

どのような構成が最適なのかはケースバイケースではありますが、ある程度の秩序を担保しようと思うと、例えば一つのコンポーネントをPresentational ComponentContainer Component に分離したりするような形で、副作用を扱うレイヤーとそうでないレイヤーを分離するような形にしたり、副作用を扱う場合はAtomic DesignでいうところのPage部分でのみ取り扱うなど、何かしらのルールを設定するのが良いと思っています。
個人的には可能な限りコンポーネントを関数のように扱い、Propsにのみ依存する作りを目指すようにしています。
無秩序に副作用を扱うとどこかで辛みが出てくることでしょう。

フロントエンドでも副作用を意識して綺麗な設計を目指していきたいですね。

参考

コンテナ・プレゼンテーションパターン
Atomic Designを分かったつもりになる

GitHubで編集を提案

Discussion