Last updated at
コードサンプルは基本的に TypeScript で書かれています。一部を除いてわかりやすいようにシンプルに書いています。 また、View 層は React で書いていますがライブラリは問いません。ただ TypeScript との相性がいいライブラリほど有用性が高まります。
この記事に書かれているコードはあくまでも実装の一例であり、実際にこの手法を利用する場合は要件や用途に合わせたコードを書いてください。
SPA が広く普及したことにより、クライアントサイドの複雑さは昔に比べて非常に増えました。特に一番複雑化に寄与しているのが UI と非同期処理の組み合わせです。 読み込みやエラーといった状態の上にビジネスロジックが乗るせいで、簡潔に書くことが難しく、バグの温床になりやすいのです。
今回は、特に頻度の高い非同期データの UI での表示の複雑さを関数型プログラミング※1の手法を取り入れて解決してみます。
なお、わかりやすくするために非同期データ=ネットワーク越しのデータ(= fetch
や XHR で取得するもの)とします。
※1 ... 正しくいうなら関数型プログラミングのものというわけではないが、ちゃんと活用しているのが主に関数型プログラミング界隈なのと関数型の要素との相性が非常にいいため。
非同期データが扱いにくいのはその状態の多さです。最小でも以下の 4 パターンの状態が存在します。
そしてこれをよくある書き方、特に手続き型なスタイルで多い方法で表すなら以下のようになるでしょう。
// サンプルとしてどこかのAPIからユーザを取得する、という想定とします
// また、以降のサンプルも全て同じ想定とします
function SampleComponent() {
// ユーザをAPIからfetchしてくるHooksとします (実装はReact固有なので省略)
// コンポーネント表示時に自動的にfetchするHooksとします
const userApi = useUserApi()
// 読み込み未開始は面倒なので省略しています
// 以降のサンプルでも全て省略します
if (userApi.isLoading) {
return <p>読込中です...</p>
}
if (userApi.error) {
return <p>エラー: {userApi.error}</p>
}
return <p>名前: {userApi.value.name}</p>
}
最小限しか書いていないのもありますが問題ないように見えます。しかし、"状態" という観点から見るととても複雑なコードとなっています。
それぞれの属性 (=変数、上記コードの場合はuserApi.isLoading
やuserApi.error
等) で状態を表現しようとすると、プログラムが表現する状態の数が実際に起きうる、もしくは必要な状態数よりもかなり多くなってしまいます。
上記コードを例にすると、起きうる状態は
の 3 つですが、コードで表現される状態は isLoading
error
value
それぞれのとりうる値の組み合わせの総数となります。「状態に応じてそれぞれちゃんと値を設定するでしょ」と思うかもしれませんが、プログラム的に制限されない = 書いている人間に全て任されることになります。人間は間違える生き物なのでプログラム的に起き得ないようにするべきでしょう。
とりうる状態を読み取ろうとする際に、結果の属性 (userApi
の中のプロパティ) を全て把握する必要があります。特に複数の処理を同時に扱ったりすると把握が非常に難しくなります。人間の脳は簡単にスタックオーバーフローを起こすので、できるだけ避けるべきでしょう。
上記コードをちゃんとした TS で書くなら以下のようになります。
function SampleComponent() {
const userApi = useUserApi()
if (userApi.isLoading) {
return <p>読込中です...</p>
}
if (userApi.error) {
return <p>エラー: {userApi.error}</p>
}
return <p>名前: {userApi.value!.name}</p>
}
違いは最後のreturn
内のuserApi.value
に!
、つまり「null
やundefined
じゃないよマーク」がついたことです。これは一般的に書かれるこのパターンの型が以下のようになっているためです。
// useUserApiの返却値の型
interface UserApi {
isLoading: boolean
error: Error | null
value: User | null // Userはユーザ情報の型定義とします
}
ロジック上はisLoading === true
、もしくはerror === null
でなければvalue === null
にはならないのですが、プログラム的にはそうじゃない場合というのが発生してきます。この Non-Nullish キャストは any
型と同じくできるだけ避けないと型システムによる安全性というものを失ってしまいます。ただ一応、直和型 (Union Type) を使うことで回避することはできますが、使い勝手が悪くなり値を提供する側 (useUserApi
の実装) も少し複雑になってしまいます。実際に View 層で扱おうとすると非常に面倒で、結果的に Non-Nullish キャストやany
が湧いてきてしまいます。
type UserApi =
| {
isLoading: true
error: null
}
| {
isLoading: false
error: Error
}
| {
isLoading: false
error: null
value: User
}
この、「フラグや変数による状態の表現」の代わりに関数型言語※2でよく使われているのが Tagged Union(Sum Types, Discriminated Union) というものです。数学が苦手なので詳しい説明はググってもらうとして、雑に言うなら区別ができて、値を持てる型
です。Haskell では Algebraic data type (代数的データ型、ADT 厳密には異なる?)、Elm では Custom Type、OCaml や ReasonML では Variant と呼ばれているみたいです。
この Tagged Union というのは「状態の表現」という目的を果たすのに最も適していると言っても過言ではないしょう。例として Elm で上記のサンプルコードを書き直してみます。なるべくシンプルに書いているので Elm を知らない/よくわからないという人でもなんとなくわかるようになっているはずです。
{--
Elmでは本来エラー用の型を使わず、Result型というものを使ってエラーを表現するのですが、
比較しやすくするために文字列でエラーを保持するようにしています
--}
type UserApi
= Loading
| Failed String
| Ok User
{--
Hooksの代わりにAPIの状態を引数で渡しています
--}
sampleComponent : UserApi -> Html Msg
sampleComponent userApi =
case userApi of
Loading ->
p [] [ text "読込中です..." ]
Failed err ->
p [] [ text ("エラー:" ++ err )]
Ok user ->
p [] [ text ("名前:" ++ user.name )]
case ~ of
はパターンマッチングと呼ばれるもので、JS で言うならそれぞれの枝が値を返すswitch
の式バージョンだと考えて大丈夫です。このサンプルでは状態に応じて返す内容の全体が変わりますが、部分的に変わるような UI でも問題なく書けます。
コードのsampleComponent
の部分を見るとわかるかと思いますが、Tagged Union や代数的データ型を使うと以下のメリットがあります。
Loading
状態のときにuser
の値は取得できないこの Tagged Union は React とそれ以降の View ライブラリのほとんどが採用している宣言的レンダリングとの相性が非常に良いのです。React は関数型ベースのデザインになっているので当然といえば当然なんですが。
※2 ... もちろん全部ではない。静的型付けの関数型(寄り)の言語は持っている気がする。あと Rust にような新しい言語にも乗ってたりする。
JS ではコンベンション、 TypeScript ではちゃんと型定義をすることで、ただのオブジェクトを使って Tagged Union を実現できます。
// TypeScriptがなくても書けますが、TypeScriptを使うことを*強く*推奨します
type UserApi =
| {
state: 'loading'
}
| {
state: 'failed'
error: Error
}
| {
state: 'ok'
value: User
}
function sampleComponent() {
const userApi: UserApi = useUserApi() // useUserApiがUserApi型を返すものとします
// if文を使って書くこともできますが、今回は返す内容が部分的ではなく全部異なるのでswitchの方が適しています
switch (userApi.state) {
case 'loading':
return <p>読込中です...</p>
case 'failed':
return <p>エラー: {userApi.error}</p>
case 'ok':
// nullからの解放!
return <p>名前: {userApi.value.name}</p>
}
}
文法が違うことを除けば Elm 版のコードと殆ど一緒です。重要なのは、オブジェクトに識別用のプロパティをもたせることです。このサンプルの場合はstate
というプロパティがその対象の状態を表します。TypeScript を使っていれば現在の状態に紐付いていないプロパティにはアクセスできなくなります。
// ...
switch (userApi.state) {
// ...
case 'failed':
// 以下のuserApi.value.nameの部分がコンパイルエラーになる
return (
<p>
エラー: {userApi.error} (ユーザ={userApi.value.name})
</p>
)
// ...
}
このデザインは意外とよく使われていて、有名なところだと Redux の Actions が採用しています。というか JS 界隈は Redux で広まったのかも...と思っちゃうくらいメインコンセプトとして使われてます。
非同期データを View 層に渡す度に逐一この型定義を書くのは無駄なので、ジェネリック型にまとめることで使いやすく、見やすくなります。
// progress: number みたいのをつけて進捗表示を出す、といったことも簡単にできます
interface Loading {
state: 'loading'
}
interface Failed<E = Error> {
state: 'failed'
error: E
}
interface Ok<T> {
state: 'ok'
value: T
}
type RemoteResource<T, E = Error> = Loading | Failed<E> | Ok<T>
型チェックがあっても文字列比較を都度書きたくない!という場合はヘルパを作るといいでしょう。
function isLoading(r: RemoteResource<any>): r is Loading {
return r.state === 'loading'
}
function isFailed<E>(r: RemoteResource<any, E>): r is Failed<E> {
return r.state === 'failed'
}
function isOk<T>(r: RemoteResource<T>): r is Ok<T> {
return r.state === 'ok'
}
また、関数型でよく使われるだけあって関数でアレコレするのと非常に相性がいいです。
// 実際のプロジェクトで使っている関数を一部改変
//
// 複数の非同期データを一つの非同期データとしてまとめる関数
// 実際に使われているものは読込中の進捗(%)もいい感じにまとめられる
const combineRemoteResources = <C, T extends any[]>(
combine: (...args: T) => C,
...resources: { [K in keyof T]: RemoteResource<T[K]> }
): RemoteResource<C> => {
const failed = resources.find(isFailed)
if (failed) {
return failed
}
const loading = resources.find(isLoading)
if (loading) {
return { state: 'loading' }
}
const allOk = resources.every(isOk)
if (allOk) {
return {
state: 'ok',
value: combine(...((resources as Ok<any>[]).map(r => r.value) as T))
}
}
return { state: 'empty' }
}
const user: RemoteResource<User>
const isJoined: RemoteResource<boolean>
// user=loading && isJoined=ok ... loading
// user=loading && isJoined=failed ... failed
// user=ok(ユーザ) && isJoined=ok(false) ... ok(null)
// user=ok(ユーザ) && isJoined=ok(true) ... ok(ユーザ)
combineRemoteResources(
(user, isJoined): User | null => (!isJoined ? null : user),
user,
isJoined
)
あくまでもこの「非同期処理の状態を Tagged Union で表す」のはロジックと View 層の境界のみにしましょう。JS には Promise/async-await といった非同期に関する扱いやすい標準があるため、ロジック内部等までこれをしてしまうと不揃いになりとても扱いにくくなってしまいます。この手法は宣言的なフローに持ち込むことによって真価を発揮します。なお、fp-ts 等を使ってアプリケーションを全部関数型チックに書くのであればこの限りではありません(そもそもそういう書き方をする人はこんな記事見ないと思いますが)。
関数型言語を使ったり、関数型のスタイルに切り替えるのはとても大変です。しかし、プログラミング言語もデザインパターンも所詮は道具、必要なときに必要な分だけ使っても全然問題ないのです。ES2015 以降の JavaScript や TypeScript は関数型言語の血が濃くなってきているので関数型界隈で使われているデザインや手法との相性がいい場面がどんどん増えてきています。関数型言語を学ぶことは実践で使える引き出しを増やすことにもなるので、実際に使う機会がなかったとしても学んでみることをおすすめします。
「この宣言的な感じいいな〜」とか「全部こんな感じで書きたいな〜」とか「言語レベルでのサポートとかパターンマッチングいいな〜」て思った人は以下のものを試してみるときっと幸せになれます。