@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 に変換する」きっかけとなっているわけです(もちろんこの挙動はElmにおけるView関数とイコールではありません、後述)。

結論としては、 この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と@ichi-h/elmishのView関数の違い

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のような何かを受け取ると、Elmのランタイム上でなんやかんやの処理を行い、ブラウザ上に実際のHTMLが表示される仕組みになっています。

一方、@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を使ったDOM操作まで、UI更新の手段を制限されずに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する設計 をあまり見たことがなかったため、そうした考え方もありなんじゃないかなと思った次第です。

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