ジンジャー研究室

長めのつぶやき。難しいことは書きません。

2023 年、改めて React と Elm Architecture を比較する

最近 React のドキュメントが新しくなったということで読んでみた。第一印象としては、とにかく懇切丁寧で React というか JavaScript すら初心者という読者でも基礎的な考え方が身に付くようになっている。ただ、深い内容まで読み進めると「同じ Virtual DOM のフレームワークでも Elm とだいぶ違うな」と改めて思った。

これはどちらが良いとか悪いということではなく、一長一短あると思う。筆者は長いこと Elm を使ってきたが React も嫌いではなく、趣味を含め色々な場面で重宝している。ただ、 Elm Architecture の提供するシンプルな仕組みには依然として価値があると思っており、それがあまり世の中に知られていないのが勿体無い。というのが、この記事を書こうと思った動機である。

昔は「部分的に取り入れても Elm メリットは享受できないから Elm やってよ」だったが、そんな余裕はなくなってきたので良いアイデアだと思ったら盗んで欲しい。

状態の居場所

React の状態はコンポーネントの各所に偏在している一方、 Elm の状態はコンポーネント*1の外にある。 次のコードは、Elm アプリケーションを定義する最もシンプルな3つの型である。

init : model
update : msg -> model -> model
view : model -> Html msg

この中の model というのが「状態」を表す型であり、init が初期状態、 update が状態を更新する関数である。見ての通り、view は model を外部から受け取る純粋な関数であり、 React の useState のように内部で状態が初期化・更新されることは決してない。このように Elm アプリケーションの状態は view とは完全に独立しているため、view のないヘッドレスなアプリケーションを init と update だけで構築してテストすることも可能である。

一方、React の場合はまずコンポーネントがなければ何も始まらない。実は React でも自前で Virtual DOM のライフサイクルを制御しようと思えばできるのだが、そんなことをしている人は誰もいない。チュートリアルや有名フレームワークはまず App というコンポーネントを定義し、その中で useState するように教えている。

コンポーネントの中で状態を扱うと、 DOM のライフサイクルの影響を受けやすい。例えば、一度マウントしたコンポーネントを一時的に消して復活させると、そのコンポーネントの状態は保存されていない。そのような状況下でも状態を維持するためには一つ上の階層にコンポーネントの状態を持っておく必要がある。

Elm では、最初から全ての状態をコンポーネントの外に持っているため、そのような問題は起こらない。また、状態をコンポーネントの外に持っているとリセット操作も直接的にできる。React でコンポーネントの状態をリセットしたいときに key を使うことがある。これはコンポーネントの状態がライフサイクルの影響を受けることを逆に利用しているのだが、直感的かどうかはやや疑わしい。

と、ここまで Elm の状態管理のメリットを述べてきたが、欠点もある。全ての状態を外で管理しているため、コンポーネントの外側にボイラープレートが増えるのだ。ただし、私見を述べると「想像するほどの面倒さではない」とは思っている。

コンポーネントの外に書かれるボイラープレートとは、例えば「あるコンポーネント A でイベントが発火したら、そのコンポーネント A の状態を更新する」というものだ。これだけ聞くと、コンポーネント内部のあらゆるイベントハンドリングを使う側が行わなければいけない(言い方を変えるとカプセル化を破壊する)ように思えるが、実際にはそうならない。コンポーネントの外側で把握すべきことは「何らかのイベントが発生したら何らかの更新をする」ということだけだ。運送業者が宅配便の中身を把握していないのと同じだ。

全ての状態がコンポーネントの外にあるということは、巨大なツリー構造のデータ(model)を描画関数(view)に渡すということになり、これも何となく乱暴に思える。が、規則的にネストすることによって、ある view が扱う状態はその view が関心のある局所的な部分だけで済むようになる。例えば elm-spa-example の Page 以下のモジュールは、それぞれのページに必要な Model や Msg が定義されており、 view もそれ以外の情報を参照しない。また、React.memo 相当の処理をする Html.lazy 関数もあるのでコスト面も問題ない。

副作用の扱い

React は useReducer という仕組みによって、状態の更新ロジックを分離することができる。ここで reducer 関数には、何らかのアクションを受け取って古い状態を新しい状態に更新する処理を書く。が、ここで reducer 関数は「純粋」である必要があり、つまりは非同期処理が書けないという問題が発生する。例えば HTTP リクエストを送信しつつ、何らかの状態を更新するには、前者をイベントハンドラ内に書き、後者を reducer で行う必要がある。すると、関連のある二つの処理が分離され、かえって見通しが悪くなる(useReducer の使い方を間違っていたら教えてほしい)。

Elm では話は至ってシンプルで、reducer 関数に相当する update 関数が副作用を同時に起こせるようになっている。これは上のコードを「副作用あり版」に変形したものだ。

update : msg -> model -> (model, Cmd msg)

Cmd というのはコマンド(Command)のことで、これを Elm ランタイムに返すことで副作用を実現する。例えば HTTP リクエストの場合、Elm ランタイムに「リクエストを送信してくれ」というコマンドを送る。すると Elm ランタイムはレスポンスを msg 型に変換し、再び update 関数を呼んでその msg を渡してくれる。Cmd msg という型は「msg 型でコールバックされるコマンド」を表している。

同じことが init にも言える。

init : (model, Cmd msg)

この型が示す通り、 Elm では初期化プロセスとして副作用を起こすことが最初から想定されており、あまり特別感はない。実際、初期データを取得するために HTTP リクエストを送信するというのはありふれた話だ。しかし何故か React のドキュメントでそのケースが扱われるのはかなり後の方で、探すのに苦労してしまった。「エスケープハッチ」扱いはどうにもモヤモヤする。

コールバック関数は update だけ

先日、同僚氏に「Elm で dispatch はどうすれば良いか」と尋ねられて、ああ確かに Elm はそういうものがないな、と思った。

なぜか。最初のコードを再掲するので、今度は view 関数の戻り値を見てほしい。

init : model
update : msg -> model -> model
view : model -> Html msg

Cmd msg が「msg 型でコールバックされるコマンド」ならば、Html msg は「msg 型でコールバックされる HTML」である。すなわち、イベントは msg の型で update 関数に渡される。何かがあれば必ず update 関数を通るし、それが唯一のコールバック関数であるということだ。この強力な性質により、 Elm のコードは誰が書いても大体似たような感じになる。

シンプルさを貫いた設計

Simple vs Easy という話がどこまで共通認識になっているのかは分からないが、 React に比べて Elm は圧倒的に「シンプル」側に倒した設計になっている。個人的に考えるシンプルさのメリットは以下のようなものだ。

  • コードを見れば何が起きているか分かる
  • 覚えなければいけない特別なルールが少ない
  • 予想外の挙動が起きにくい

ただし、これらのメリットによって喜ぶ程度は、かなり人によりバラツキが大きいように思う。ぶっちゃけ、シンプルで美しいからと言って機能性の乏しさを我慢できるのはオタクだけなのではないかと思ったりする。実際、ボイラープレートが多いのは客観的に見ても面倒だと思っており、「シンプルさのために多少の面倒は我慢しなさいよ」と言うのは乱暴だろう。

しかし、考えてみれば古来から言われてきた Model と View の分離、 React も是としている「ビューは純粋な関数」を何よりも体現できているのが、この Elm Architecture であると思う。ある種の完成された形として後世に受け継いでいきたい。

Elm Architecture に関しては公式ガイドがかなり分かりやすく書いてあるので、詳しくはこちらを参照してほしい。 guide.elm-lang.org

*1:Elm コミュニティはコンポーネントという呼び方を避けているが、この記事は React ユーザ向けに書かれているので気にしないことにする