React Hooks APIでThe Elm Architecture
皆さんThe Elm Architectureは好きですか?私は大好きです。
代数的データ型は好きですか?私も大好きです。
The Elm Architecture(以下TEA)の良さを挙げると
- 不変であり、副作用を分離する
- 余積の手厚いコンパイラサポート
- 1つのことを(単純に)行う方法はほぼ1通り
- エラーメッセージが分かりやすい
- 実行時エラーが(理論上)無い(ことになっている)
などなど色々あると思います。
TypeScriptでどこまで再現できるか試してみました。
※必ずしもElmの劣化版というわけではなく、TypeScriptおよびReactならではの良さも見つかりました。
React Hooksの標準フックにはuseReducer
という、Elmで言うところのBrowser.sandbox
に相当するフックがあります。
この度は、これを非同期処理に対応させたuseTea
フックを作成して色々遊んでみました。🍵
成果物はこちら(codesandbox)とこちら(npm)です。
不変性と副作用の分離
TypeScriptで不変性や副作用の分離を潔癖に再現するのは不可能ですが、
React利用者にはそこまで困難はないはずです。
特にフック時代のReactではuseState
やuseEffect
で不変性と副作用について意識する習慣がつくはずです。
純粋関数原理主義の皆さんはElmかPureScriptかHaskellを使える環境に移住しましょう。
余積のコンパイラサポート
ElmやPureScriptのようにガッツリとサポートがあるわけではありませんが、
一応TypeScriptでもそれなりの恩恵を受けることができます。
まずMsg
を定義します。
type CounterMsg =
| "increment"
| "decrement"
| "delayed-increment";
文字列ベタ書きだと侮るなかれ。
これはランタイムの文字列型ではなくコンパイルタイムで区別されるリテラル型なので
静的言語の恩恵を受けることができます。
例えば、後でswitch
文で使う際に、IDEを使用していれば候補が出ます。
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カウンター
このカウンターの機能の説明をします。
+1
ボタンと-1
ボタンは同期的にカウントを増減させますが、
Delay +1
ボタンはクリックした1秒後にカウントを増やします。
Delay +1
は非同期処理が絡む機能なのでReact.useReducer
ではなくuseTea
の出番です。
useTea
フックを使うために用意しなければならないものは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ログインフォーム
とりあえず最小限のモデルを先に定義してしまいます。
このモデルは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 カウンタートリビア
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つ増やします。
- HTTP通信を開始する
fetch-trivia
- HTTP通信の成功を通知する
succeeded-fetch-trivia
- 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
では、trivia
をLoading
状態に設定します。
succeeded-fetch-trivia
でtrivia
をSuccess
状態にします。
failed-fetch-trivia
でtrivia
をFailure
状態にします。
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
フックといくつかの実践例の紹介でした。
面白そうだなと思っていただけた方は是非遊んでみてください。
Discussion