@ichi-h/elmish - フレームワーク非依存の状態管理ライブラリを作っているお話

2024-02-24
2024-03-27

はじめに

@ichi-h/elmish という、UIフレームワークに依存せず、TS/JSの資産を活かしつつElmishに状態管理が行えるライブラリを作りました。

アプリケーションロジックとビューを分離することで、コードの責務が明確になるとともに、ロジックの可搬性やTastableなコードも保証できます。

とりあえず使ってみよう

以下のコマンドでインストールできます。

npm install @ichi-h/elmish

はじめにデータ構造とメッセージを定義しましょう。

// data.ts
import { elmish } from "@ichi-h/elmish";

// Modelのデータ構造
export type Model = {
  count: number;
  loader: "idle" | "loading";
};

// ユーザーの行動に対応するメッセージ
export type Message =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "startReset" }
  | { type: "endReset" };

// Modelの初期値
export const init: Model = {
  count: 0,
  loader: "idle",
} as const;

// 状態管理を行う関数の生成
export const useElement = elmish<Model, Message>();

次に、各メッセージに対するロジックを定義しましょう。

// update.ts
import { Update } from "@ichi-h/elmish";

import { Model, Message } from "./data";

// 現在のmodelと送信されたmessageを受け取る
export const update: Update<Model, Message> = (model, message) => {
  switch (message.type) {
    case "increment": {
      // 新しいmodelを返却することで、状態を更新できる
      return { ...model, count: model.count + 1 };
    }

    case "decrement": {
      return { ...model, count: model.count - 1 };
    }

    case "startReset": {
      // 新しいModelと一緒に、Model更新後に実行する非同期な処理もタプルで返却できる
      return [
        { ...model, loader: "loading" },
        async () => {
          return new Promise((resolve) => {
            // 1秒後にendResetのメッセージを送信
            setTimeout(() => {
              resolve({ type: "endReset" });
            }, 1000);
          });
        },
      ];
    }

    case "endReset": {
      return { ...model, count: 0, loader: "idle" };
    }
  }
};

最後に、このロジックを好きなフレームワークに組み込みます。例えばReactであれば以下のように書けます。

import { init, useElement } from "./data";
import { update } from "./update";

export const App = () => {
  const [model, view] = useState(init);

  // メッセージを送信する関数を取得
  const send = useElement(
    model, // 状態の初期値
    update, // Modelを更新する関数
    view // 新しいModelを受け取って、UIを更新する関数
  );

  const increment = () => send({ type: "increment" });
  const decrement = () => send({ type: "decrement" });
  const reset = () => send({ type: "startReset" });

  return (
    <div>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>reset</button>
      <button onClick={increment}>+</button>
      {model.loader === "loading" && <p>loading...</p>}
      {model.loader === "idle" && <p>count is {model.count}</p>}
    </div>
  );
};

Vueであればこうです。

<script setup lang="ts">
import { ref } from "vue";

import { Model, init, useElement } from "./data";
import { update } from "./update";

const model = ref(init);
const view = (newModel: Model) => (model.value = newModel);

// メッセージを送信する関数を取得
const send = useElement(model.value, update, view);

const increment = () => send({ type: "increment" });
const decrement = () => send({ type: "decrement" });
const reset = () => send({ type: "startReset" });
</script>

<template>
  <div>
    <button type="button" @click="decrement">-</button>
    <button type="button" @click="reset">reset</button>
    <button type="button" @click="increment">+</button>
    <p v-if="model.loader === 'loading'">loading...</p>
    <p v-else-if="model.loader === 'idle'">count is {{ model.count }}</p>
  </div>
</template>

フレームワークを使わずに、Vanilla JS/TSだっていけちゃいます。

import { Model, init, useElement } from "./data";
import { update } from "./update";

function setupCounter(
  counter: HTMLParagraphElement,
  incrementBtn: HTMLButtonElement,
  decrementBtn: HTMLButtonElement,
  resetBtn: HTMLButtonElement,
) {
  const view = (newModel: Model) => {
    if (newModel.loader === "loading") {
      counter.innerHTML = "loading...";
    } else {
      counter.innerHTML = `count is ${newModel.count}`;
    }
  };

  // メッセージを送信する関数を取得
  const send = useElement(init, update, view);

  incrementBtn.addEventListener("click", () => send({ type: "increment" }));
  decrementBtn.addEventListener("click", () => send({ type: "decrement" }));
  resetBtn.addEventListener("click", () => send({ type: "startReset" }));

  send({ type: "startReset" });
}

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <div>
    <button id="decrement" type="button">-</button>
    <button id="reset" type="button">reset</button>
    <button id="increment" type="button">+</button>
    <p id="counter"></p>
  </div>
`;

setupCounter(
  document.querySelector<HTMLParagraphElement>("#counter")!,
  document.querySelector<HTMLButtonElement>("#increment")!,
  document.querySelector<HTMLButtonElement>("#decrement")!,
  document.querySelector<HTMLButtonElement>("#reset")!,
);

すごくないですか?(なぜこんなことができるのかは後ほど)

なぜ作っているのか?

フレームワークと密結合になるアプリケーションロジック

バックエンドですと、(規模にも依りますが)Clean ArchitectureやDDDといった設計に沿って、 フレームワークとビジネスロジックを切り離して実装する ことがよくあると思います。例えば、このサイトの裏で動いているサーバーの、記事取得のエンドポイントのロジックは以下のようになっています。

// slugから記事を1件取得する関数の型
type ShowWork = string -> Result<Work option, string>

// アプリケーションロジックを発火するために必要なデータ構造
type ShowWorkInput = { showWork: ShowWork; slug: string }

// 記事取得のビジネスロジック
let interactor (input: ShowWorkInput) : ShowWorkOutput =
    result {
        let! work =
            input.showWork input.slug // slugを引数に記事を取得
            |> Result.mapError InfrastructureError // 取得時にエラーが起きたらインフラでエラーが起きたことを返す

        match work with
        | Some w -> // 記事が取得できた場合
            return!
                Ok
                    { slug = w.slug
                      category = categoryToString w.category
                      title = w.title
                      description = w.description
                      body = w.body
                      thumbnailUrl = w.description
                      publishedAt = w.publishedAt.ToString "yyyy-MM-dd"
                      updatedAt = w.updatedAt.ToString "yyyy-MM-dd" }
        | None -> return! Error NotFoundError // 記事が取得できなかった場合はNotFoundを返す
    }

上記のユースケースの中にWebフレームワーク依存のものはなく、依存性をInputを通してDIすることで、純粋なアプリケーションのロジックだけを持つことができます。

このようにすることで、ソフトウェアの変更に強い、真の意味で「ソフト」なコードに保つことができます。

なのですが、(私の周りだけかもしれませんが)フロントエンドで「クリーン」なロジックを持っているプロジェクトに関わったり、見たことがあまりありませんでした。例えばReactであれば、よく見かけるのは以下のようなコードです。

const useCount = () => {
  const [count, setCount] = useState(0);
  const [loader, setLoader] = useState<"idle" | "loading">("idle");

  const increment = () => setCount((prev) => prev + 1);
  const decrement = () => setCount((prev) => prev - 1);
  const reset = () => {
    setLoader("loading");
    setTimeout(() => {
      setCount(0);
      setLoader("idle");
    }, 1000);
  };

  return { count, increment, decrement, reset, loader };
};

export const App = () => {
  const { count, increment, decrement, reset, loader } = useCount();

  return (
    <div>
      <button onClick={decrement} disabled={loader === "loading"}>
        -
      </button>
      <button onClick={reset} disabled={loader === "loading"}>
        reset
      </button>
      <button onClick={increment} disabled={loader === "loading"}>
        +
      </button>
      {loader === "loading" && <p>loading...</p>}
      {loader === "idle" && <p>count is {count}</p>}
    </div>
  );
};

状態と、その状態にまつわるロジックをCustom Hooksとしてまとめ、コンポーネントで呼び出して使うのがよくあるコードの例だと思います。

なのですが、こういたしますと カウントにまつわるロジックがフレームワークのエコシステムに組み込まれているため、この2つは密結合となってしまっています。

これにより、

  • フレームワークが破壊的変更を出したときに、ユースケースが変わったわけではないのに改修する必要がある
  • アプリケーションロジックの中にフレームワーク特有の書き方が混ざって本質的なところが見えにくくなることがある
  • ロジックをテストするときにフレームワークの都合を考慮する必要が出てくる
  • 現在使用しているフレームワークから他フレームワークへの移行が大変

などの問題が起こります。

この、アプリケーションロジックとフレームワークが密結合してしまっている問題をどのように解消すればよいでしょうか?

Elm Architecture

ところで、Elm ArchitectureはElm言語で採用されているWebフロントエンドアプリケーションを構築するためのアーキテクチャです。私がここで解説するよりもElmのドキュメントの方がわかりやすいので詳細は割愛いたしますが、少し引用させていただきます。

Elm のプログラムが動く仕組みを図にすると、こんな風になります。

Elm Architecture

Elm が画面に表示するためのHTMLを出力し、コンピュータは画面の中で起きたこと、例えば「ボタンがクリックされたよ!」というようなメッセージを Elm へ送り返します。

さて、Elm プログラムの中では何が起きているのでしょうか? Elm では、プログラムは必ず3つのパーツに分解できます。

  • Model — アプリケーションの状態
  • View — 状態を HTML に変換する方法
  • Update — メッセージを使って状態を更新する方法

この3つのコンセプトこそ、The Elm Architecture の核心なのです。

- The Elm Architecture · An Introduction to Elm

要は、 Model という状態と、その状態をHTMLとして出力する View 、状態更新を担う Update の3つによってElmは成り立っている、ということです。

Viewの依存性逆転

ここで注目したいのは View関数 です。これは先のReactの例に当てはめると、const [count, setCount] = useState(0);setCount がそれに近い役割に当たります。

setCount では引数に新しい状態を受け取り、その状態に依存するコンポーネントの再レンダリングを行いますが、つまりこれが「状態を HTML に変換する」きっかけになっているわけです。

結論としては、 このView関数さえ抽象化できれば、アプリケーションロジックとUIフレームワークは切り離す ことができます。

どういうことか。

Elmもそうなのですが、ReactやVueといった最近のフレームワークは宣言的UIを採用するものが多くあります。

宣言的UIとは、端的に言えば「状態を更新すれば、DOMを触るといった手続き的な操作を考えずとも、自動的にUIを更新してくれるコンセプト」を指します。このコンセプトは「状態を更新するロジックと、UIを更新するロジックは切り離せる」という事実に基づいています。

つまり依存関係がこうなるのではなく、

flowchart BT
  subgraph Logic[Complex Logic]
    direction LR
    Update <--> View
  end
  Logic --> Model

これでもいけるよ、ということですね。

flowchart BT
  Update --> Model
  View --> Model

そこで、もしUpdateとViewを切り離せるのであれば、「Model + Update View」という持ち方も可能だと思いませんか?

flowchart BT
  subgraph "Application Logic"
    Update --> Model
    IView[View Interface] --> Model
  end
  subgraph "UI Framework"
    View -. "DI" .-> IView
  end

つまり、ModelとUpdateでアプリケーションロジックを固め、抽象化されたViewに向けて外から実体を注入してあげれば、アプリケーションロジックとUIフレームワークは分離可能 なはずであり、そしてこの仮説を検証したのが @ichi-h/elmish だ、ということです。

なぜElm Architectureを採用したのか?

私がHaskellやF#といった関数型言語が好きなので、その感覚でロジックを書きたいという個人的な理由もございますが、それを抜きにしても、Webフロントエンドにおいて、 シンプルさと堅牢さの両立と、ドメインに対する表現の説得力や妥当性の高さは他にない と考えます。

Elmを触っていて思うのは、Model、View、Updateという住み分けが言語レベルで守られているため、雑に書いたとしても基本良いコードにしかならない(良いコードしか動いてくれない)ので、コンパイルが通った時の安心はとても高いです。おまけにコードも追いやすい。

もちろんこれはElmのエコシステムだからこそ実現できることなのですが、だからといってElmでしか意味を成さないものというわけではありません。

現にElm Architectureは広く受け入れられており、Webフロントエンドのみならず、デスクトップアプリ、モバイル、ゲームといった、様々なアプリケーションの設計に影響を与えてきました。

私の言う説得力や妥当性とはこのことで、この事実は、世の中に存在するドメインのパターン――特に「対話」に関するところの本質をElm Architectureがうまく突いているからこそのものだと考えます。

補足: Elmと@ichi-h/elmishのView関数の違い

ElmのView関数の型は Model -> Html msg となっており、何らかのModelを受け取ってHTMLのような何かを返すことを要求します。

-- https://guide.elm-lang.jp/architecture/buttons.html#view

view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

ElmがこのHTMLのような何かを受け取ると、裏で「なんやかんや」の処理を行い、ブラウザ上に実際のHTMLが表示される仕組みになっています。

「なんやかんや」というのはUIの更新処理、つまりは副作用なのですが、Elmではこの処理をランタイムが持っているため、「なんやかんや」の処理がコード上に漏れることはありません。

これにより、Elmはコードレベルの純粋性を保つことができるのです。

一方、@ichi-h/elmishのView関数の型定義は (model: Model) => void となっており、こちらはModelを受け取って何も返さないことを要求します。

// React
const [model, view] = useState(init);

// Vue
const model = ref(init);
const view = (newModel: Model) => (model.value = newModel);

// Vanilla TS/JS
const view = (newModel: Model) => {
  if (newModel.loader === "loading") {
    counter.innerHTML = "loading...";
  } else {
    counter.innerHTML = `count is ${newModel.count}`;
  }
};

これは何を意味しているのかというと、ElmのようにHTMLのような何かを返せばあとはイイ感じにやってくれるというものではなく、 ブラウザに実際のHTMLを表示するところまで 実装することを要求しています。

これにより、ReactやVueといった宣言的UIを採用するフレームワークから、Vanilla JS/TSを使った手続き的なUI更新まで、UI周りの手段を制限されずにView関数を実装することができます。

このように同じView関数といっても、実はそれぞれの捉え方は大きく異なっているのです。

嬉しいこと

@ichi-h/elmishを使用するメリットは、大きく4つあります。

コードの責務が明確になる

アプリケーションロジックとUIフレームワークを分離すると、アプリケーションロジックとビューを分離しやすくなり、それぞれの責務が明確となるコードが自然と生まれます(もちろん、書き方に依ってはこの2つの境界を「ぶち壊す」ことは可能なので注意は必要です)。

アプリケーションロジックの可搬性が上がる

フロントエンドを開発する場合、何かしらのフレームワークを使うことが一般的になっていると思うのですが、今後もっと魅力的なフレームワークが出てくることもあるでしょう。

過去に仕事でVueからReactへのリプレイスを行ったことがあるのですが、VueとReactの差分を解消するのはなかなかに大変でした。というのも、そのVueの書き方がまさにアプリケーションロジックとベッタリくっ付いた書き方になってしまっていたので、ロジックの再利用がほぼできず、結局すべて書き直すことになってしまったのですね。

そういったときにアプリケーションロジックが再利用できると、リプレイスの工数はぐんっと落ちます。

Testable

実は@ichi-h/elmishでは、アプリケーションロジックに干渉するのはUpdate関数やMessageの 型のみ で、それ以外でライブラリがロジックに干渉することはありません。

// update.ts
import { Update } from "@ichi-h/elmish"; // 型のみインポート

import { Model, Message } from "./data";

// update関数のロジック自体に、@ichi-h/elmishが干渉することはない
export const update: Update<Model, Message> = (model, message) => {
  switch (message.type) {
    case "increment": {
      return { ...model, count: model.count + 1 };
    }
  }
};

そのため、ロジックのテストをとても素直に書けます。追加でプラグインを当てたり、難しいことを考える必要はありません。

// update.test.ts
test("カウントを1増やす", () => {
  const model: Model = { count: 0, loader: "idle" };
  const message: Message = { type: "increment" };
  const updated = update(model, message);
  expect(updated).toStrictEqual({ count: 1, loader: "idle" });
});

JS/TSの資産をフルに活用しつつ、Elmishに記述できる

Elmを使うと、ElmからJSを呼び出すのが結構面倒です。Elmでは他言語で採用されているようなFFIの仕組みをあえて採用しない代わりに、プログラムの安全性を保障しているようです(詳しくは 制限事項 · An Introduction to Elm から)。

なのですが本音としては、やはりめんどくさいです。

もちろん、Elmの主張は間違いなく正しく、私もそれに同意しています。その一方で、全てのケースにおいてそうした哲学が正しいかと問われると、そういうわけではありませんし、Elmもそのことを認めています。

「Elmishに開発したい! でももっと自由に開発できたってよいじゃない!」

そんな選択があっても良いと思うのです。

問題点

ここまでの内容を読んで、 「いやいや、結局アプリケーションロジックを@ichi-h/elmishに依存させてるじゃん」 と考える方もいらっしゃると思います。

おっしゃる通りです。

このライブラリは、アプリケーションロジックをフレームワークから分離することには成功しましたが、 @ichi-h/elmishを新たな依存関係に据える必要がある ので、根本的な問題は解決していません。

そのため、真に「クリーン」なコードを目指す場合は、このライブラリを使うべきではありません。

元々@ichi-h/elmishは、私がフロントエンドの開発をしているときに作ったデザインパターンを、他プロジェクトでも使いまわすためにライブラリとして切り出したものです。なので私個人の視点から見れば、仮に破壊的変更があったとしても、それはフロントエンドアーキテクチャの理解が変わったことを意味するので、許容できるものなのですね。

おわり

このライブラリの使う・使わないは別として、 ViewをDIする設計 をあまり見たことがなかったため、そうした考え方もありなんじゃないかなと思った次第です。

この考え方が何かの参考になりましたら幸いです。