🍵

React Hooks APIでThe Elm Architecture

2021/08/02に公開

皆さんThe Elm Architectureは好きですか?私は大好きです。

代数的データ型は好きですか?私も大好きです。

The Elm Architecture(以下TEA)の良さを挙げると

  1. 不変であり、副作用を分離する
  2. 余積の手厚いコンパイラサポート
  3. 1つのことを(単純に)行う方法はほぼ1通り
  4. エラーメッセージが分かりやすい
  5. 実行時エラーが(理論上)無い(ことになっている)

などなど色々あると思います。
TypeScriptでどこまで再現できるか試してみました。
※必ずしもElmの劣化版というわけではなく、TypeScriptおよびReactならではの良さも見つかりました。

React Hooksの標準フックにはuseReducerという、Elmで言うところのBrowser.sandboxに相当するフックがあります。
この度は、これを非同期処理に対応させたuseTeaフックを作成して色々遊んでみました。🍵

成果物はこちら(codesandbox)こちら(npm)です。

不変性と副作用の分離

TypeScriptで不変性や副作用の分離を潔癖に再現するのは不可能ですが、
React利用者にはそこまで困難はないはずです。
特にフック時代のReactではuseStateuseEffectで不変性と副作用について意識する習慣がつくはずです。

純粋関数原理主義の皆さんはElmかPureScriptかHaskellを使える環境に移住しましょう。

余積のコンパイラサポート

ElmやPureScriptのようにガッツリとサポートがあるわけではありませんが、
一応TypeScriptでもそれなりの恩恵を受けることができます。

まずMsgを定義します。

type CounterMsg =
  | "increment"
  | "decrement"
  | "delayed-increment";

文字列ベタ書きだと侮るなかれ。
これはランタイムの文字列型ではなくコンパイルタイムで区別されるリテラル型なので
静的言語の恩恵を受けることができます。

例えば、後でswitch文で使う際に、IDEを使用していれば候補が出ます。
switch文での候補表示
switch文での候補表示

また、抜け漏れがある場合もコンパイルエラーで教えてくれます。
どのパターンの考慮漏れがあるのかも表示されます。
switch文での抜け漏れ表示
switch文での抜け漏れ表示

ただ、実際にReal Worldでの開発となるとただのEnumもどきでは事足りない場面が多く、
種類ごとに異なるフォーマットの値を保持したい場面が出てきます。
例えばRemoteData型。

-- elm
type RemoteData e a
    = NotAsked
    | Loading
    | Failure e
    | Success a

TypeScriptでこれを再現するには、型コンストラクタをリテラル型で代用して……

type RemoteData<TErr, TData> =
  | { status: "NotAsked" }
  | { status: "Loading" }
  | { status: "Success"; data: TData }
  | { status: "Failure"; err: TErr };

これでほぼほぼ再現できます。
パターンマッチングで分解するときはstatusを基準にswitch文を使います。
リテラル型扱いになるのでタイポ時はコンパイルタイムでエラーを検出できます。

  switch (remote.status) {
    case "NotAsked":
      return <></>
    case "Loading":
      return <p>loading...</p>;
    case "Success":
      return <p>{remote.data}</p>
    case "Failure":
      return <p>{remote.err}</p>
  }

これもちゃんとremote.まで打てばIDEによる補完が効きます。
抜け漏れチェックも(少し手間がありますが)できます。

代数的データ型が欲しいだけならまだTypeScriptでもどうにかこうにかやり繰りできないこともないです。

React HooksでTEA

いよいよ本題です。

Elmではしばしば「コンポーネントは悪」という概念があります。
Viewにローカルステートを持たせないように極力取り除くことで、
Viewをただの関数にしたほうが色々便利という経験則に基づいています。

React公式の三目並べチュートリアルでも、「子コンポーネントが持つローカルな状態を
抽出して親コンポーネントに埋め込み直す」という手順が
リファクタリングの典型例として頻出します。

そうは言ってもグローバルに何でもかんでも詰め込むのはやめたいと思うのが
人情というものなので、これをフックで気軽に使えるようにしてみました。
フック化することにより、既存のプロジェクトにも難なく追加することができます。

まずはシンプルなカウンターを例にuseTeaを使ってみます。

TEA Counter
TEAカウンター

このカウンターの機能の説明をします。
+1ボタンと-1ボタンは同期的にカウントを増減させますが、
Delay +1ボタンはクリックした1秒後にカウントを増やします。

Delay +1は非同期処理が絡む機能なのでReact.useReducerではなくuseTeaの出番です。
useTeaフックを使うために用意しなければならないものは4つあります。

  1. モデルの型
  2. メッセージの型
  3. モデルの初期状態
  4. 更新関数

まずはコンポーネントのモデルです。
カウンターでは単純にカウントの数値だけあれば良いでしょう。

type CounterModel = {
  count: number;
};

次はコンポーネントのメッセージです。
今回は+1する操作、-1する操作、1秒後に+1する操作の3つの操作を定義します。

type CounterMsg =
  | "increment"
  | "decrement"
  | "delayed-increment";

3つ目はコンポーネントの初期状態です。
Elm同様、モデルとコマンドのタプルで表現します。
TypeScriptではタプルを長さ2の配列として表します。
この型はよく使うのでTeaPair型という名前を付けてみました。

type TeaPair<TModel, TMsg> = [TModel, Cmd<TMsg>]

カウンターの初期状態は次のように定義できます。

const counterInit: TeaPair<CounterModel, CounterMsg> = [
  { count: 0 },
  Cmd.noneAs()
];

カウントの初期値は0で、コマンドでは何もしません。

最後はコンポーネントの更新関数です。
これもElm同様です。
関数の型もよく使うのでTeaUpdate型という名前を付けてみました。
TeaPairとはモデルとメッセージの順番が逆になっていますが、これはElmリスペクトです。

type TeaUpdate<TMsg, TModel> = (msg: TMsg, model: TModel) => TeaPair<TModel, TMsg>

カウンターコンポーネントの更新関数は次のように定義できます。
increment時とdecrement時は単純で、モデルカウントを増減させて終了です。
delayed-increment時は1秒後にincrementメッセージを発火します。
このようなコールバック的挙動を実現するためにはCmd.ofSubを使います。

const counterUpdate: TeaUpdate<CounterMsg, CounterModel> = (msg, model) => {
  switch (msg) {
    case "increment":
      return [{ count: model.count + 1 }, Cmd.noneAs()];

    case "decrement":
      return [{ count: model.count - 1 }, Cmd.noneAs()];

    case "delayed-increment":
      return [
        model,
        Cmd.ofSub((dispatch) => {
          setTimeout(() => {
            dispatch("increment");
          }, 1000);
        })
      ];
  }
  exhaustiveCheck(msg);
};

exhaustiveCheck関数は開発者体験用の単なるヘルパー関数であり、無くても動作します。
これを書いておくと、switch文の場合分けに抜け漏れがあったときの
コンパイル時のエラーメッセージが少しだけ分かりやすくなります。

function exhaustiveCheck(bottom: never): never {
  throw new Error("Exhaustive check failed.")
}

いよいよ4つの部品が揃ったのでuseTeaフックを使います。

function Counter() {
  const [model, dispatch] = useTea(counterInit, counterUpdate, []);

  return (
    <div>
      <p>Count: {model.count}</p>
      <div>
        <button onClick={() => { dispatch("increment"); }}>+1</button>
        <button onClick={() => { dispatch("decrement"); }}>-1</button>
        <button onClick={() => { dispatch("delayed-increment"); }}>Delay +1</button>
      </div>
    </div>
  );
}

ビューとロジックの責務を分離しつつ、驚くほど簡単に実装できました!

フォームとlocalStorage

次のようなログイン画面(の一部)を作成してみます。

TEA Login Form
TEAログインフォーム

とりあえず最小限のモデルを先に定義してしまいます。
このモデルは3つの入力欄(ユーザー名、パスワード、チェックボックス)のデータを保持します。

type LoginFormModel = {
  username: string;
  password: string;
  remember: boolean;
};

さて、次はメッセージです。

先ほどのカウンターでは各メッセージは種類だけを区別できれば問題ありませんでしたが、
今回の例では、メッセージにstringを格納したり、booleanを格納したりする必要があります。

そこで、全メッセージにtypeというプロパティを共通で持たせることで解決します。
※リテラル型になるので静的言語の恩恵を受けることができる

type LoginFormMsg =
  | { type: "set-username"; username: string }
  | { type: "set-password"; password: string }
  | { type: "set-remember"; remember: boolean }

モデルの初期状態を宣言します。

const loginFormInit: TeaPair<LoginFormModel, LoginFormMsg> = [
  {
    username: "",
    password: "",
    remember: false,
  },
  Cmd.noneAs()
];

更新関数を宣言します。
オブジェクトのスプレッド記法を使うことで、一部のプロパティだけの変更を
簡潔に表現できます。

const loginFormUpdate: TeaUpdate<LoginFormMsg, LoginFormModel> = (
  msg,
  model
) => {
  switch (msg.type) {
    case "set-username":
      return [{ ...model, username: msg.username }, Cmd.noneAs()];

    case "set-password":
      return [{ ...model, password: msg.password }, Cmd.noneAs()];

    case "set-remember":
      return [{ ...model, remember: msg.remember }, Cmd.noneAs()];
  }
}

msg.typeの場合分けに抜け漏れがあるとコンパイル時にエラーとして検出されます。

後はフックを使うだけです。

function LoginForm() {
  const [model, dispatch] = useTea(loginFormInit, loginFormUpdate, []);

  return (
    <div>
      <div>
        <label>Username</label>
        <input
          type="text"
          value={model.username}
          onChange={(e) => {
            dispatch({
              type: "set-username",
              username: e.currentTarget.value
            });
          }}
        />
      </div>
      <div>
        <label>Password</label>
        <input
          type="password"
          value={model.password}
          onChange={(e) =>
            dispatch({
              type: "set-password",
              password: e.currentTarget.value
            })
          }
        />
      </div>
      <div>
        <label>
          <input
            type="checkbox"
            checked={model.remember}
            onChange={(e) =>
              dispatch({
                type: "set-remember",
                remember: e.currentTarget.checked
              })
            }
          />
          Remember me
        </label>
      </div>
      <div>
        <button>Login</button>
        <button>Reset</button>
      </div>
    </div>
  );
}

さてここで、もし前回のログイン時に「Remember me」で
ユーザー名がlocalStorageに保存されていれば
コンポーネントのマウント時にユーザー名の初期値をその値で埋める
という機能を実装してみましょう。

変更するべきはloginFormInitのコマンドです。
Cmd.ofSubでコールバックを作成し、その中でlocalStorageを読みます。
もし値があればset-usernameをディスパッチしてユーザー名をセットします。

const loginFormInit: TeaPair<LoginFormModel, LoginFormMsg> = [
  {
    username: "",
    password: "",
    remember: false,
  },
  Cmd.ofSub((dispatch) => {
    const username = localStorage.getItem("username");
    if (username) {
      dispatch({ type: "set-username", username });
    }

    const remember = localStorage.getItem("remember");
    if (remember) {
      dispatch({ type: "set-remember", remember: remember === "true" });
    }
  })
];

簡単に実装できました!

HTTP通信

コールバックにはCmd.ofSubを使用すると説明しましたが、
Promiseベースの非同期処理にはCmd.ofPromiseBuilderを使用します。
よくあるタスクの1つがfetch APIによるHTTP処理です。

そこで、カウンターの例に戻って説明します。
現在のカウント数に関するトリビアをnumbersapi経由で取得して表示してみます。

TEA Counter Trivia
TEA カウンタートリビア

Cmd.ofPromiseBuilderの第1引数には() => Promise<TMsg>を指定します。
第2引数には失敗時の処理(err: Error) => TMsgを指定します。

Cmd.ofPromiseBuilder(
    () =>
    fetch(`http://numbersapi.com/${model.count}`)
        .then((response) => response.text())
        .then((data) => ({
          type: 'succeeded-fetch-trivia',
          data,
        })),
    (err) => ({ type: 'failed-fetch-trivia', err }),
),

HTTP通信を行う際のモデルには先ほど定義したRemoteDataを使うと便利です。
モデルにtriviaというプロパティを増やします。

type CounterModel = {
  count: number;
  trivia: RemoteData<Error, string>;
};

triviaの初期値はNotAskedです。

const counterInit: TeaPair<CounterModel, CounterMsg> = [
  { count: 0, trivia: { status: 'NotAsked' } },
  Cmd.noneAs(),
];

メッセージを3つ増やします。

  1. HTTP通信を開始するfetch-trivia
  2. HTTP通信の成功を通知するsucceeded-fetch-trivia
  3. HTTP通信の失敗を通知するfailed-fetch-trivia

succeeded-fetch-triviaではHTTP通信のレスポンス(トリビア)が必要です。
failed-fetch-triviaではエラー理由が必要です。

そこで、すべてのメッセージをただのリテラル型ではなく、
typeで分類するように変更します。

type CounterMsg =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'delayed-increment' }
  | { type: 'fetch-trivia' }
  | { type: 'succeeded-fetch-trivia'; data: string }
  | { type: 'failed-fetch-trivia'; err: Error };

これによって、それぞれのメッセージにデータを梱包することができるようになりました。

さて、更新関数に3つのメッセージの場合分けを追加します。
fetch-triviaでは、triviaLoading状態に設定します。
succeeded-fetch-triviatriviaSuccess状態にします。
failed-fetch-triviatriviaFailure状態にします。

const counterUpdate: TeaUpdate<CounterMsg, CounterModel> = (msg, model) => {
  switch (msg.type) {
    case 'increment':
      return [{ ...model, count: model.count + 1 }, Cmd.noneAs()];

    case 'decrement':
      return [{ ...model, count: model.count - 1 }, Cmd.noneAs()];

    case 'delayed-increment':
      return [
        model,
        Cmd.ofSub((dispatch) => {
          setTimeout(() => {
            dispatch({ type: 'increment' });
          }, 1000);
        }),
      ];

    case 'fetch-trivia':
      return [
        { ...model, trivia: { status: 'Loading' } },
        Cmd.ofPromiseBuilder(
          () =>
            fetch(`http://numbersapi.com/${model.count}`)
              .then((response) => response.text())
              .then((data) => ({
                type: 'succeeded-fetch-trivia',
                data,
              })),
          (err) => ({ type: 'failed-fetch-trivia', err }),
        ),
      ];

    case 'succeeded-fetch-trivia':
      return [
        { ...model, trivia: { status: 'Success', data: msg.data } },
        Cmd.noneAs(),
      ];

    case 'failed-fetch-trivia':
      return [
        { ...model, trivia: { status: 'Failure', err: msg.err } },
        Cmd.noneAs(),
      ];
  }
};

後はトリビアを表示するだけなのですが、
ここでローカルステートを抽出してただの関数にするというテクニックが活きます。

モデルのtriviaの型RemoteData<Error, string>を引数として
ReactElementを返すただの関数を作成します。

※TypeScriptではこの引数の型をCounterModel['trivia']と書くことができる!

function viewTrivia(trivia: CounterModel['trivia']) {
  switch (trivia.status) {
    case 'NotAsked':
      return (
        <p>
          To view trivia about the current counter value, click on the "Submit"
          button!
        </p>
      );
    case 'Loading':
      return <p>loading...</p>;
    case 'Success':
      return <p>{trivia.data}</p>;
    case 'Failure':
      return <p>{trivia.err.message}</p>;
  }
}

後はこれをコンポーネント内で使うだけです。

function Counter() {
  const [model, dispatch] = useTea(counterInit, counterUpdate, []);

  return (
    <div>
      <p>Count: {model.count}</p>
      <button onClick={() => { dispatch({ type: 'increment' }); }} >+1</button>
      <button onClick={() => { dispatch({ type: 'decrement' }); }} >-1</button>
      <button onClick={() => { dispatch({ type: 'delayed-increment'}); }}>Delay +1</button>
      <button onClick={() => { dispatch({ type: 'fetch-trivia' }); }}>Submit</button>
      {viewTrivia(model.trivia)}
    </div>
  );
}

これはloadingフラグ等を持たせて管理するよりも、
より正確にモデリングできているためアプリケーションが堅牢になります。

viewTriviaで行ったようなローカルステートの抜き出しを繰り返して、
useTeaの使用箇所をルートコンポーネントの<App />のみで行うようにすると
オリジナルのThe Elm Architectureになります。

まとめ

以上、useTeaフックといくつかの実践例の紹介でした。
面白そうだなと思っていただけた方は是非遊んでみてください。

GitHubで編集を提案

Discussion