ジンジャー研究室

長めのつぶやき。難しいことは書きません。

優先順位が口癖になる危機感

開発サイクルの終盤に近づくと「今回は優先順位の高いここまでを実装して、残りは優先順位が低いのでまたの機会にしましょう」という話になりがちだ。自分もこれまで何度もそうしてきたし、その場の判断としては正しい。が、このやり方に味をしめて常にこの調子で進めて、なんとなく上手く仕事をこなしている気になってしまうことには危機感がある。

以下、普段考えていることを自戒を込めてメモしておく。(なお、筆者の経験は toB ・Web 系・自社開発が中心なので読者の置かれている状況とは一致しないかもしれない)

優先度が低いタスクに着手する機会が一生訪れない

仮にあるタスクの優先度を下げたとする。バックログを眺めるとそのタスクに着手できそうなのは3ヶ月後だ。そして3ヶ月後、やっとそのタスクに着手できるかというと、そんなことは決してない。3ヶ月の間にそれよりも優先度の高いタスクが積まれているからだ。タスクを消化する速度とタスクが積まれる速度を比較した時、「消化する速度=積まれる速度」だとしたら、「3ヶ月後に着手可能」タスクは、3ヶ月経ってもやはり「3ヶ月後に着手可能」のままだ。さらに「消化する速度<積まれる速度」の場合は、時間が経てば経つほど4ヶ月後、5ヶ月後、と着手予定日がどんどん延びていく。そういうわけで、一度優先順位を下げたら最後、なんらかの力学が働かない限りそのタスクに着手する機会は一生訪れない。

常に及第点の品質

すると、どうなるか。「今は 60 点だけど後で 90 点にしよう」と思っていた機能の品質は、その先もずっと 60 点のままだ。その機能を 90 点にする前に次の機能開発が始まる。そして、次の機能開発もやはり 60 点で終了する。このようにして常に及第点を取り続けてしまう。しかし、ここで期限を延長して 90 点を取るまで開発するという判断はできない。この機能を 60 点から 90 点にするよりも、次の機能を 0 点から 60 点にする方が優先順位が高いからだ。

表に出てこない UX の悪さ

ここで犠牲になるのが UX で、「とりあえず必要な機能を満たしているから大丈夫」という判断で使いにくい状態で機能がリリースされる。特に toB だと「業務が回る」ことが最優先され、運用者・決済者を満足させることに比べて利用者の体験は後回しにされがちだ。また、中途半端にスクラムなどを齧っていると(自戒)「最低限欲しいものから順に小出しにリリースしよう」という思考になるので危険だ。小出しにリリースしたいのは検証サイクルを早くするためであって、最低限で許してもらうためではないし、きちんと検証していなければ意味がない。しかし UX が悪いことによる影響を測るのはとても難しい。「ネガティブフィードバックがあったら考える」で運よくフィードバックをもらえることもあるが、多くは不便が顕在化して分かりやすい部分であって、残りは言語化されず深層意識の中に不満が蓄積されていく。ユーザーは「なんとなく使いづらい」と感じているが、それを直接フィードバックをすることなく利用をやめてしまい、「なんだか分からないけど定着率が悪いですねぇ」ということになる。A/B テストという方法はあるが、それが行われるのはユーザー体験にフォーカスした時のみで、優先順位の都合でユーザー体験を妥協した時ではない。

筋の悪い設計

内部品質も犠牲になる。新たに追加しようとしている機能が既存の設計に上手く乗らない場合、根本から考えて作るのはコストがかかるので、ちょっと誤魔化して筋の悪い設計で作ろうという判断になりがちだ。いわゆる技術的負債である。もちろんそうせざるを得ない場合もあるが、単に怠慢の場合も多い。隣のチームとコミュニケーションを取るのはコストがかかるし、なるべく面倒の少ない方法を取りたい。特に、当初開発コストを小さく見積もっていたが着手したらコストが大きいとわかった場合、とても焦る。「1日で終わると思っていたけど、ちゃんとした設計で作ると1週間だ」となった時に「今はちょっと誤魔化して実装して、後で設計を見直しましょう」となる。そして負債を返済する機会はなかなか訪れない。

強い人は最初から 90 点を取る

しかし、ちょっと賢い人々が「優先順位」を振りかざして「今は最低限の機能でリリースしましょう」「計測してから考えましょう」と色々こねくり回している間に、強々な人が最初から 90 点の品質で仕上げてくることを忘れてはいけない。並の人が作る初期品質は 60 点なので 90 点まで上げるのにとても時間がかかってしまう(ので残りはやらないという判断になる)が、最初から 90 点ならコストは問題にならない。そうすると、同じ時間で 60 点を取り続ける人と 90 点を取り続ける人がいて、それがそのままプロダクトの品質になる。「あの人は時間がない中で沢山レビューで指摘してきて面倒だな...」となった時に、それは自分が 60 点を取るからではないのかというのは自問した方が良い。エンジニアの話をしているが、デザイナーや PdM にも当てはまると思う。並の人が「やってみないと分からない」と言っているうちに、センス抜群の人がやらずに答えを出しているのではないかと思うことがある。

強くありたい

その場その場で「優先順位」をもとに判断するのはもちろん正しい。上手くマネジメントすれば後回しにしたものに着手する機会が生まれるかもしれない。が、そもそも品質の低いものをどうやってリリースするかに頭を捻られなくて済むだけの圧倒的な実力を身につけたいものである。

QA と出会う

現職はスタートアップには珍しく、しっかりした QA 組織がある。日々一緒に仕事をしていて色々と発見があるので、今思うことをメモしておく。

前提として、自分は今までちゃんとした QA の人と仕事をしたことがなかった。前職はアルバイトの人に QA 相当の仕事をやってもらっていたが別に専門性があるわけではなかった。前前職には QA 組織が存在していたが、自分は技術基盤的な組織に所属していてプロダクトを作っていたわけではなかったので、関わりは全くなかった。 そうなると、そういう職種・組織が存在するということを本で読んで知ってはいたが実際どのように連携して仕事をしているのか分からない。そういう人々と関わってみたいというのが転職動機の一つでもある(ちなみに QA と同様に PdM という職種とも関わりがなかったので興味があった)。 そして9ヶ月ほど経った今になってみると「よく今まで QA なしで開発してたな」と思う。

(ところで QA ってロールを指したりプロセスを指したりでややこしいが、この記事ではロールを指している)

継続的なテスト活動

いま一緒に仕事をさせてもらっている QA は出来上がった成果物に対してテストを実施するだけでなく、企画段階から関わって仕様の明確化を手伝い、設計ドキュメントを読んで細かい仕様を質問し、テスト計画書を開発者にレビューしてもらい、そして最後にテストを実施しながら逐次フィードバックするという一連の活動をしている。このように、なるべく前工程から活動を開始することを「シフトレフト」と呼ぶようだ(恥ずかしながらこの言葉を知ったのは最近のこと)。また、テスト設計・実施だけではなくプロセス全体に関わって品質向上に貢献することを「テスト活動」と界隈では呼んでいるようだ。

テスト技法

QA がスプレッドシートにテスト項目をずらっと並べてチェックしているのを眺めていたのだが、あれは「ディシジョンテーブル(DT)」と呼ばれる歴とした技法であることを後から知った。同じエンジニアなのにテストの話をする時に今まで全然そういうワードが出てきたことがなかったなと思った。面白そうだったので、有名そうな「はじめて学ぶソフトウェアのテスト技法」を読んだ後、おすすめで出てきた「ソフトウェアテスト技法練習帳 ~知識を経験に変える40問~」を一通り解いてみた。結構気づきが多くてユニットテストや E2E テストにも応用できそう。

品質保証という観点

E2E テストは QA と開発者が同時に関わっているのだが、テストケースの見方が大分違うなという感想を得た。一言で言うと、我々開発者が適当すぎた(笑)。開発者はユニットテストについて語る時「不安なところにテストを書いて自信をつけよう」などと言うが、「不安とか自信とかなんなの」感は正直ある。ユニットテストの延長で E2E テストも「まーこんなもんだろ」みたいに感覚で書くので、多分 QA としては困惑していたと思う。結局「そのテストでどこまで品質を保証できていますか?」に答えるためには、テストケースも整然と管理されている必要がある。

コミュニティ

QA 界隈にも WACATE とか JaSST のようなイベントが頻繁あり、コミュニティが形成されているようだ。ホント全然知らなかった。観測範囲では、個別の技法を突き詰める話よりは QA が組織にどう関わるかみたいな話が多い印象。まあ重要だからそうなるの分かる。

いま考えていること

そもそもなぜ今 QA 活動に興味があるかというと、最近設計ドキュメントの粗を QA に指摘されまくって凹んだからである。「それちゃんと考えればケースが漏れてること気づいただろう」と。つまり QA がテスト設計を始める前に開発者が設計段階でミスに気づければ、その分だけ戻りが少なくなりシフトレフトに貢献できるわけ。でも QA が旗を振って開発者にそういう活動をしてもらうようにお願いするのってかなり難しい気がするので、じゃあもう自分が開発者として直接協力するのが一番早いでしょうと。痒いところにも手が届くので。 そして、前回の反省からの TRY として直近の機能では設計時に PdM・デザイナーと仕様をとことん議論してみた。単に意識を変えただけだが、おかげで今度は QA に質問攻めにあって泣きながら書き直すことは無さそうだ(多分)。

近況:転職して半年

7月から新しい職場で働いているので、そろそろ半年。整理のために近況を綴ってみる。以下は個人的な話で会社の紹介とかはしないので、気になった人はホームページを見てほしい。

kwork.studio

前職に引き続き、職種としてはフロントエンドエンジニアという位置付け。

業務として書く React

前職では業務で Elm を書いていたので React は小さなプロジェクトや趣味で触っていた程度。なので大規模アプリケーションで必要となるような React の知見は当然なく、 Suspense とかもちょっと聞いたことある程度でそもそも何なのかよく分かっていなかった。あとは Next.js とか Storybook とか Jest とか MSW とか Recoil とか TanstackQuery みたいなフレームワーク・ライブラリも未経験のものが多く、何からキャッチアップすればいいのやら途方に暮れた。が、流石に半年も触っていたのでだいぶ分かってきた。

それまで触っていた Elm はシンプルで綺麗な唯一の方法を提供する一方で、禁欲的というか「そんな無理して複雑な事をするならボイラープレートを書いてた方がマシでしょ」みたいなところがある。React (というかフロントエンドは大体そう)は真逆で、大勢がとにかく物凄いエネルギーとコストを投じて理想を追求していく。自分はミニマリスト気質なのもあって前者で満足していたのだが、正直ブルーオーシャンすぎて5年もするとやることが無くなってしまった(それは良いことでもある)。現職は React をバリバリやる感じなので正直ついていけるのかという不安はあったものの、他にもいろんな要因が重なり「もう一度フロントエンドやり直してみるか〜」という気持ちになった。

そういうわけで、それまで横目でウォッチしていた数年分のフロントエンド技術を一気にキャッチアップすることとなった。心配していたのは、ずっと Elm をやっていたせいで React のやり方に拒否反応が出て「Elm ではこんな風に書くんだけどなぁ」みたいな事をつい口走って煙たがられないか、というところだったが、蓋を開けたら全然そんなことはなくて安心している。アンラーンは特に意識していたので順調に出来たし、むしろいろんな新しい書き方を覚えて発想の幅が広がった。転職前の思惑通りとにかく学びが多い。

フロントエンドガチ勢の集い

そもそも転職のきっかけは現職のフロントエンドエンジニアからお誘いを受けたこと。その方は界隈でも有名な方だということは知っていたのだが、入社して一緒に仕事をしてみるとやっぱり凄い。フロントエンドガチ勢は CSS の手札がとにかく多い。多い時は知らない文法を4、5個見つける日があったほど。しかも同等の実力者が何人もいたりする。

前職ではフロントエンドが実質的に自分一人のような状態だったので、設計をゼロから好き勝手に出来た一方で他の人から学ぶことも難しかった。そうすると、自分の発想の幅の中で問題を解決するのだが、なかなか手札が増えていかない。70 点が求められる仕事で 80 点の解決策を思いついたらそこで終わってしまい、90 点以上の解決策は一生知ることができない。

人によって得意分野と不得意分野があってパラメータが上手く凸凹になっているのも面白い。ある人は CSS が得意、別の人はライブラリにめちゃ詳しい、Figma を使いこなす人もいればアクセシビリティに強いこだわりを持っている人もいる。なのでお互いに学び合えるし、それらが集合知になっていくのが良い。

職種の多い開発組織

フロントエンド内でも多種多様なスキルを持った人が居るのだが、開発組織全体でも同じことが言える。デザイナー・PdM が多数在籍するほか、スタートアップには珍しく QA 組織(内製)がある。あとセキュリティとかイネーブルメントをやるグループがあったり。プロダクトの開発者はフロントエンドとバックエンドが明確に分かれている。もちろんフルスタックなスキルを持ったメンバーも居るが、皆がそうであるよりはそれぞれが専門性を活かした方が良いという思想が根底にある。

前職ではエンジニアのレベルが総じて高かったが、職種のバリエーションは少なかった。なので、本などで「世の中にはそういう職種(例えば PdM とかデザイナーとか QA とか...ただし正社員で)が存在するらしい」ということは知っていたが実際に見たことはなかったので、そういう人々のいる環境に興味があった。自分はどっちかというと、誰か器用な人が片手間にやったり大勢がたまに集まって何かするよりも、ある専門領域をガッツリ見る人が居た方が上手く回るだろうと信じているので、その辺の思想ともマッチした。

というわけで、色んな人が居て面白い。ただし、そのぶん開発プロセスを整えるのは難しい。それはもう本当に。

最近の興味

最近フロントエンドの中で担当分野を決める動きがあったので「テスト(ユニット, 結合, E2E)」の担当になってみた。理由としては、相対的に手薄かつ他にやりたい人が居なかったから。設計とか UI コンポーネントとかも興味なくはないけど、他にやりたい人・得意な人が沢山いるからいいやという感じ。テストは書き慣れていないとどんどん腰が重くなるので、書くのが面倒そうな場所を開拓してハードルを下げていきたい。

Vitest の Browser Mode (experimental) でファイル読み込みのテストを書く

趣味でブラウザ上に画像や音声を読み込んで作業する React アプリを作っているのだが、 Vitest + Testing Library でテストをしようと思ったらファイル読み込み部分でつまづいた。Node.js 上でブラウザ環境をシミュレートしている部分がそのままでは上手く動かないので、 polyfill を入れたり沢山モックを差し込んだりするとなんとか動く。が、色々弄りすぎて本当にテスト出来ているのか怪しいし、やはりリアルなデータでテストしたい。

で、リアルなブラウザ環境でテスト出来ないかなと調べていたところ、2つの候補が挙がった。

両方とも experimental 。前者は Vitest をそのままブラウザ上で実行するというもので、後者は Playwright のテストをコンポーネント単位で行えるようにするもの。どちらも一長一短だが、今回はアサーションもブラウザ上で行うのが都合が良かったので前者の Vitest Browser Mode を採用した。

導入

導入は簡単。vite.config.ts の testbrowser の設定を追加するだけ。デフォルトでは webdriverio が使われるのだが、馴染みのある playwright を使うことにする。

export default defineConfig({
  ...
  test: {
    ...
    browser: {
      enabled: true,
      provider: "playwright",
      name: "chromium",
      headless: true,
    },
  },
});

このように設定を変えてテストを実行すると、あれこれライブラリが足りないと親切なプロンプトが出るので従っていくと色々インストールしてくれる。(CI には npx playwright install を追加する必要がある)

テストが再び動くまで

上記の手順でとりあえずテストを起動できるようにはなるのだが、そのままでは正常に動かなかった。 @vitejs/plugin-react can't detect preamble というエラーに遭遇して途方に暮れていたところ、Vitest のコントリビュータが書いている example を発見。この example は公式ドキュメントにマージされていないので、 2023/9 現在この fork されたブランチでしか見ることができない。惜しい。

まず、 setupFiles におまじないを入れると上記エラーが消える。

// 引用: https://github.com/vitest-dev/vitest/blob/userquin/feat-isolate-browser-tests/examples/react-testing-lib-browser/src/test/setup.ts
import "@testing-library/jest-dom";

if (typeof window !== "undefined") {
  // @ts-expect-error hack the react preamble
  window.__vite_plugin_react_preamble_installed__ = true;
}

あとは、ブラウザ環境なので本物の DOM の上にマウントするようにテストコードを書き換える必要がある。

// 引用: https://github.com/vitest-dev/vitest/blob/userquin/feat-isolate-browser-tests/examples/react-testing-lib-browser/src/utils/test-utils.tsx
export async function createContainer(id: string, node: ReactNode) {
  const container = document.createElement('div')
  container.setAttribute('id', id)
  document.body.appendChild(container)
  const root = createRoot(container)
  root.render(node)

  await nextTick()

  return root
}

testing-library を使っている場合、 root の代わりに within(root) を返しておくと screen と同じように使えたりする。

ところで、上のコードの通りにすると複数の root からそれぞれ React アプリが起動するため、同じグローバルオブジェクトを参照していたりすると問題になることがある。自分のケースでは jotai の Provider で <App> を囲むなどして問題を回避することができた。

他に細かい部分では timers/promisessetTimeout が応答しなくなったりしたのでブラウザ標準の setTimeout で書き直したりした。

が、問題としてはそのくらいで、テストの本質的な部分に関してはほぼノータッチで移行することができた。 polyfill にも別れを告げることができた。

実ファイルを読み込むまで

ブラウザ環境で動かすため、テストに使うファイルを fs モジュールで入手することが出来なくなってしまった。そこで、代わりに Vite の loader を使って直接 import した。Vite では import foo from "foo.bar?raw" のように書くとファイルをテキスト形式で読み込むことが出来るが、バイナリは対応していないようだったので、自前で用意した。

const arrayBufferLoader = () => ({
  name: "arraybuffer-loader",
  transform(_code: any, id: string) {
    const [path, query] = id.split("?");
    if (query != "buffer") {
      return null;
    }
    const hex = fs.readFileSync(path, "hex");
    return `
    const hex = "${hex}";
    const arrayBuffer = new Uint8Array(hex.match(/../g).map(h => parseInt(h, 16))).buffer;
    export default arrayBuffer;
    `;
  },
});

export default defineConfig({
  plugins: [arrayBufferLoader(), ... ],
  ...
});

参考: javascript - Import raw image data into a script with vite - Stack Overflow

これで import foo from "foo.bar?buffer" のようにして ArrayBuffer を読み込むことが出来るようになった。ただしこのままだと foo の型が分からないと TS のコンパイラに文句を言われるので、vite-env.d.ts に次のような宣言を加える。

declare module "*?buffer" {
  const src: ArrayBuffer;
  export default src;
}

これで @ts-ignore とか as とか使わなくても ArrayBuffer として扱われる。嬉しい。

その他、モックの制約など

今回は最終的に使わなくなったのだが、vi.mock が Browser Mode では動かないと書かれている。その Issue は未だ open だが、vi.hoisted が実装されたことにより named export に対して spyOn できるようになったとのこと。

it's now possible to use vi.spyOn on an ES module named export in browser environment

実際やってみたら確かに問題なく動いていそうだった。

実際の移行の様子

github.com

感想

  • ファイル読み込みのテストでモックまみれになっていた部分がごっそりなくなってスッキリした
  • テストの本質的な部分に関してはほぼ書き換えずに移行できた
  • Playwright を起動する時間だけ立ち上がりが遅いが、個人的には全く気にならないレベルだった

ブラウザ上のテスト実行、流行るといいなぁ。

Deno Deploy で WebAuthn を使ったサイトを作ってみた

作ったもの

Kaleidoshare という、オンライン万華鏡を作って共有できるサービスを作ってみた!

kaleidoshare.deno.dev

コードはここ。

github.com

作品は Twitter で公開できる。

まだまだ粗い部分が多いのだが、そもそもの目的が技術検証なのでまあこのくらいの完成度で良いでしょうということで(メンテするのがめんどくさい)。でも面白いと思ったら試してみてね。

動機

  • Deno Deploy は Cloudflare Workers のようにエッジに簡単にデプロイできるサービスの中でもパフォーマンスで群を抜いているらしく、以前から気になっていた。最近 Deno KV という分散ストレージが使えるようになったので試してみたい。
  • せっかくなので WebAuthn によるパスワードレス認証も試してみたい。最近はプラットフォームのパスキー対応が進んでいるのでデバイスを跨げるはず。
  • ついでに最近のフロントエンドのツールを色々キャチアップしたい。

この辺を満たす適当なお題を考えたところ、万華鏡に行き着いた。

サインアップ・ログイン体験

指紋認証でパスワード不要。最高。

サインアップ

ログイン

この例ではデバイスに保存しているけど、パスキーで登録すれば別のデバイスでもログインできる。

感想など

  • 開発時のコードがそのまま Deno Deploy 上でも動いて素晴らしい。サーバーレス環境で動くようにコアレベルで抽象化されていて、例えるなら express で作ったアプリが Lambda 上で動くような感じ。KV も LocalStorage くらいの手軽さで使えて面倒なセットアップが不要。
  • Deno 自体には慣れる必要がある。deno.json に import map を書く方法とか、エディタのプラグインの使い方とか(再起動・ライブラリのキャッシュが必要)。Node.js 互換は日が浅いので制限があり、例えば esm.sh を使うなどして回避する必要がある。最初は Vite も Deno で実行していたがバグがあったので諦めて Node.js に切り替えた。今後に期待。
  • Deno KV は value のサイズが 64KB に制限されており、作品ごとの OGP 画像がそのままでは保存できなかった。仕方がないので image uri を分割して保存して、取得時に結合して復元するということをした。あまり大きなデータの保存は想定されていないのかもしれない。
  • WebAuthn はクライアント側が簡単なので油断していたらサーバー側がしんどすぎたのでライブラリに頼った。とはいえフローは簡単で、サインアップ・ログイン共に「サーバーにリクエスト -> サーバーが生成した情報をブラウザに渡す -> ユーザーが認証ステップを踏む -> 結果をサーバーに返す -> サーバーで検証して情報を保存」をすればいい。
  • Playwright で WebAuthn を動作させるには Chromium で virtual authenticator を使う。ただしこの方法ではログイン時にユーザを指定しないと動作しなかったため、テスト時だけはサインアップの時と同じユーザー名をサーバーに送信するようにした。
  • まだまだベストプラクティスが分からない。パスキーはデバイスを跨げるがプラットフォーム(Apple, Google, Microsoft)は跨げない。一応それを考慮して複数のクレデンシャルを登録できるようにしてはみたが、そんな罠を知っているユーザーは稀なので不慮の事故が避けられない。復帰用にメールアドレスなどを登録した方が良いかも。

(Appendix) 要素技術一覧

想像以上に多岐に渡る技術をキャッチアップ出来て面白かった(本当は Deno Deploy や WebAuthn 以外にも色々工夫した部分を語りたいのだが、流石に話題がとっ散らかるので泣く泣く削った)。

2023 年、改めて React と Elm Architecture を比較する

最近 React のドキュメントが新しくなったということで読んでみた。第一印象としては、とにかく懇切丁寧で React というか JavaScript すら初心者という読者でも基礎的な考え方が身に付くようになっている。ただ、深い内容まで読み進めると「同じ Virtual DOM のフレームワークでも Elm とだいぶ違うな」と改めて思った。

これはどちらが良いとか悪いということではなく、一長一短あると思う。筆者は長いこと Elm を使ってきたが React も嫌いではなく、趣味を含め色々な場面で重宝している。ただ、 Elm Architecture の提供するシンプルな仕組みには依然として価値があると思っており、それがあまり世の中に知られていないのが勿体無い。というのが、この記事を書こうと思った動機である。

昔は「部分的に取り入れても Elm メリットは享受できないから Elm やってよ」だったが、そんな余裕はなくなってきたので良いアイデアだと思ったら盗んで欲しい。

状態の居場所

React の状態はコンポーネントの各所に偏在している一方、 Elm の状態はコンポーネント*1の外にある。 次のコードは、Elm アプリケーションを定義する最もシンプルな3つの型である。

init : model
update : msg -> model -> model
view : model -> Html msg

この中の model というのが「状態」を表す型であり、init が初期状態、 update が状態を更新する関数である。見ての通り、view は model を外部から受け取る純粋な関数であり、 React の useState のように内部で状態が初期化・更新されることは決してない。このように Elm アプリケーションの状態は view とは完全に独立しているため、view のないヘッドレスなアプリケーションを init と update だけで構築してテストすることも可能である。

一方、React の場合はまずコンポーネントがなければ何も始まらない。実は React でも自前で Virtual DOM のライフサイクルを制御しようと思えばできるのだが、そんなことをしている人は誰もいない。チュートリアルや有名フレームワークはまず App というコンポーネントを定義し、その中で useState するように教えている。

コンポーネントの中で状態を扱うと、 DOM のライフサイクルの影響を受けやすい。例えば、一度マウントしたコンポーネントを一時的に消して復活させると、そのコンポーネントの状態は保存されていない。そのような状況下でも状態を維持するためには一つ上の階層にコンポーネントの状態を持っておく必要がある。

Elm では、最初から全ての状態をコンポーネントの外に持っているため、そのような問題は起こらない。また、状態をコンポーネントの外に持っているとリセット操作も直接的にできる。React でコンポーネントの状態をリセットしたいときに key を使うことがある。これはコンポーネントの状態がライフサイクルの影響を受けることを逆に利用しているのだが、直感的かどうかはやや疑わしい。

と、ここまで Elm の状態管理のメリットを述べてきたが、欠点もある。全ての状態を外で管理しているため、コンポーネントの外側にボイラープレートが増えるのだ。ただし、私見を述べると「想像するほどの面倒さではない」とは思っている。

コンポーネントの外に書かれるボイラープレートとは、例えば「あるコンポーネント A でイベントが発火したら、そのコンポーネント A の状態を更新する」というものだ。これだけ聞くと、コンポーネント内部のあらゆるイベントハンドリングを使う側が行わなければいけない(言い方を変えるとカプセル化を破壊する)ように思えるが、実際にはそうならない。コンポーネントの外側で把握すべきことは「何らかのイベントが発生したら何らかの更新をする」ということだけだ。運送業者が宅配便の中身を把握していないのと同じだ。

全ての状態がコンポーネントの外にあるということは、巨大なツリー構造のデータ(model)を描画関数(view)に渡すということになり、これも何となく乱暴に思える。が、規則的にネストすることによって、ある view が扱う状態はその view が関心のある局所的な部分だけで済むようになる。例えば elm-spa-example の Page 以下のモジュールは、それぞれのページに必要な Model や Msg が定義されており、 view もそれ以外の情報を参照しない。また、React.memo 相当の処理をする Html.lazy 関数もあるのでコスト面も問題ない。

副作用の扱い

React は useReducer という仕組みによって、状態の更新ロジックを分離することができる。ここで reducer 関数には、何らかのアクションを受け取って古い状態を新しい状態に更新する処理を書く。が、ここで reducer 関数は「純粋」である必要があり、つまりは非同期処理が書けないという問題が発生する。例えば HTTP リクエストを送信しつつ、何らかの状態を更新するには、前者をイベントハンドラ内に書き、後者を reducer で行う必要がある。すると、関連のある二つの処理が分離され、かえって見通しが悪くなる(useReducer の使い方を間違っていたら教えてほしい)。

Elm では話は至ってシンプルで、reducer 関数に相当する update 関数が副作用を同時に起こせるようになっている。これは上のコードを「副作用あり版」に変形したものだ。

update : msg -> model -> (model, Cmd msg)

Cmd というのはコマンド(Command)のことで、これを Elm ランタイムに返すことで副作用を実現する。例えば HTTP リクエストの場合、Elm ランタイムに「リクエストを送信してくれ」というコマンドを送る。すると Elm ランタイムはレスポンスを msg 型に変換し、再び update 関数を呼んでその msg を渡してくれる。Cmd msg という型は「msg 型でコールバックされるコマンド」を表している。

同じことが init にも言える。

init : (model, Cmd msg)

この型が示す通り、 Elm では初期化プロセスとして副作用を起こすことが最初から想定されており、あまり特別感はない。実際、初期データを取得するために HTTP リクエストを送信するというのはありふれた話だ。しかし何故か React のドキュメントでそのケースが扱われるのはかなり後の方で、探すのに苦労してしまった。「エスケープハッチ」扱いはどうにもモヤモヤする。

コールバック関数は update だけ

先日、同僚氏に「Elm で dispatch はどうすれば良いか」と尋ねられて、ああ確かに Elm はそういうものがないな、と思った。

なぜか。最初のコードを再掲するので、今度は view 関数の戻り値を見てほしい。

init : model
update : msg -> model -> model
view : model -> Html msg

Cmd msg が「msg 型でコールバックされるコマンド」ならば、Html msg は「msg 型でコールバックされる HTML」である。すなわち、イベントは msg の型で update 関数に渡される。何かがあれば必ず update 関数を通るし、それが唯一のコールバック関数であるということだ。この強力な性質により、 Elm のコードは誰が書いても大体似たような感じになる。

シンプルさを貫いた設計

Simple vs Easy という話がどこまで共通認識になっているのかは分からないが、 React に比べて Elm は圧倒的に「シンプル」側に倒した設計になっている。個人的に考えるシンプルさのメリットは以下のようなものだ。

  • コードを見れば何が起きているか分かる
  • 覚えなければいけない特別なルールが少ない
  • 予想外の挙動が起きにくい

ただし、これらのメリットによって喜ぶ程度は、かなり人によりバラツキが大きいように思う。ぶっちゃけ、シンプルで美しいからと言って機能性の乏しさを我慢できるのはオタクだけなのではないかと思ったりする。実際、ボイラープレートが多いのは客観的に見ても面倒だと思っており、「シンプルさのために多少の面倒は我慢しなさいよ」と言うのは乱暴だろう。

しかし、考えてみれば古来から言われてきた Model と View の分離、 React も是としている「ビューは純粋な関数」を何よりも体現できているのが、この Elm Architecture であると思う。ある種の完成された形として後世に受け継いでいきたい。

Elm Architecture に関しては公式ガイドがかなり分かりやすく書いてあるので、詳しくはこちらを参照してほしい。 guide.elm-lang.org

*1:Elm コミュニティはコンポーネントという呼び方を避けているが、この記事は React ユーザ向けに書かれているので気にしないことにする