こんにちは、アーキテクト室の岩永です。
ヘンリーでは去年からプロダクトのフルリニューアルを進めており、その一環としてフロントエンド開発に MobX を導入しました。
当初は複雑なUIの実装を整理するための View Model として限定的に使い始めましたが、その有用性から徐々に使用範囲を広げ、現在では新規開発のほぼ全てで MobX を活用しています。
本記事では、MobX の採用に至った経緯や、実際の運用を通じて得られた知見を共有します。 特に以下のポイントについて詳しく説明していきます。
- MobX の特徴と基本的な使い方
- フルリニューアルにおける MobX 採用の背景
- 段階的な導入プロセスと直面した課題
- Domain Model への MobX の活用と今後の展望
フロントエンド開発における状態管理の選択に悩まれている方や、複雑なドメインのプロダクトを作っている方、大規模なリニューアルプロジェクトに取り組まれている方の参考になれば幸いです。
MobX とは
MobX は値の変更を監視することができるデータ型 (observable) を作るためのライブラリです。
Plain object/array だけでなくクラスに対しても使え、React との連携も容易で、シンプルなコードでリアクティブプログラミングを実現できます。この特徴により、View Model の実装として利用されることが一般的です。
MobX の詳細については以下の記事が詳しいので、ぜひご覧ください。
ここでは雰囲気を掴みたい方のために、シンプルな例を紹介します。
1. Observable なクラスを定義する
import { observable, computed, action } from "mobx"; class Doubler { @observable accessor value: number; // @observable は value プロパティを監視可能にする constructor(value: number) { this.value = value; } @computed // @computed は返り値をメモ化しつつ、中で参照されている observable の値が変化したら再計算する get double() { return this.value * 2; } @action // @action は observable の値を更新する処理であることを明示する increment() { this.value++; } }
2. クラスを普通に使う
import { autorun } from "mobx"; const doubler = new Doubler(1); doubler.value; // 1 doubler.double; // 2 autorun(() => console.log(doubler.double)); // autorun は初回と値が変化するごとに実行される // Console: 2 doubler.increment(); // Console: 4
3. React で使う
const DoublerView: React.FC<{ doubler: Doubler }> = observer(({ doubler }) => { // observer は値が変化したら再レンダリングする return <div>{doubler.double}</div>; });
フルリニューアル
これまで外部発信などで触れてこなかったのですが、ヘンリーでは去年からプロダクトのフルリニューアルを行っています。詳細は別の機会に譲りますが、フルリニューアルの主な経緯は以下の通りです。
- 当初クリニック (外来) 向けだった製品を、後に中小病院 (入院) 向けへと方針転換 (2022年頃)
- 病院向けは要件が複雑で全体像が見えないまま、クリニック向けの延長線上に機能拡張を積み重ねる
- 組織・プロセスの改善で病院業務の解像度が上がってくると、現状の製品と実態の乖離が明らかに
- 根幹の情報設計に限界が生じ開発速度が低下、プロダクト全体の再設計が必要と判断 (2024年)
1. 初手の機能開発で MobX を先行導入
Henry は扱っているドメインが複雑なため、UI ではその複雑さをできるだけ感じさせないようにするために、ビジュアル面でもインタラクション面でもさまざまな工夫がされています。 それゆえ全体的に見た目はシンプルですが UI の実装の難易度は高くなりやすい傾向にあります。
特にリニューアルの初手で開発することになったある機能は、GUI プログラミングとしての複雑性が高く、臨床ドメインとしての複雑性もそれなりにあるものでした。この2つの複雑性が混ざることが、今後のメンテナンスを困難にする主要な原因となるだろうと考えました。
そこで抽象化・構造化の方針として、まず「臨床ドメイン」と「UI のドメイン」を明確に分離することにし、また、特に複雑な後者を UI 基盤 (フレームワーク) として切り出した設計を取ることにしました。
ここで実現したかったことは、以下の通りです。
- UI を抽象化・構造化しやすい
- 複雑なレイアウトを抽象化できる
- 複雑なインタラクションを抽象化できる
- コンポーネントと状態を分離できる
- UI (表現)とドメイン(ビジネスロジック)を分離しやすい
- 表示データの種類の増加や表現のカスタマイズ要求に柔軟に対応できる
これらの要件を実現するため、GUI プログラミングに詳しい知人に相談したところ、本人もよく使っているという MobX をお勧めされました。クラスベースで View Model を実装できる設計思想に惹かれ、実際に PoC を作成してみたところ、私たちの要件に非常にマッチしていたため採用を決定しました。
良かったこと
- 仕組みが非常によくできており、インターフェースがシンプルで覚えるべき記法・ルールが少ない
- MobX 本体と React 連携は別パッケージとして分離されており、React への依存を最小限に抑えられる
- ViewModel をクラスで書ける
- オブジェクト指向プログラミングにおけるプラクティスや抽象化テクニックをそのまま使える
- コンポーネントと状態の分離を指南しやすい
- React hooks でもある程度できるが自由度が高すぎて強制が難しい
- 将来想定している共同編集のアーキテクチャへの移行がしやすい
- テストを書きやすい
- 記述に React hooks のような冗長性がない
- コンポーネントが実際に参照しているプロパティが変化したときのみレンダリングが走るため効率が良い
- ローカルステート・グローバルステートどちらの管理にも使える
- 値の書き込みは同期的・アトミックなので直感的である
- React hooks は状態の更新が非同期(レンダリングサイクルと一体)であるため処理が冗長になりがち。例えば、ボタンの多重送信防止を正しく実装するには useRef と useState の両方を組み合わせる必要があるなど
懸念だったこと
- 標準的な React のパラダイム (hooks, pure function, immutability) から離れる
- クラスの継承は便利な機能だが、過度な使用は複雑性を増大させる可能性があるため、適切な設計と使用が重要である
- MobX のエッジケースでの挙動で問題が発生する可能性がある (かもしれない)
- ※ 今のところ実際にはこういう問題には遭遇していない
- MobX が同期処理・アトミック性を思想として掲げているので、複雑な derivations や reactions が増えてくると scripting cost のチューニングが大変になる可能性がある
代替案
クラスベースで observable をうまく実装しており、React との親和性もあるライブラリは MobX が唯一無二です。 atom や reducer ベースの他の状態管理ライブラリについても検討はしましたが、どれも求めている抽象化・構造化の側面ではソリューションが弱いため採用しませんでした。
2. 全面的な導入
先行導入で MobX の有用性を実感できたため、全面的な導入を決定しました。 MobX へのロックインは懸念材料でしたが、他により良い選択肢が見当たらなかったことも理由の1つです。
特に既存資産との相性が悪かったため、先行導入での機能実装を進める中で、自然と周辺機能も MobX で実装したいという気持ちが高まっていました。 課題としては以下の3点です。
- Mutable vs immutable の衝突
- 素の React や既存資産が immutable を期待しているので、observable object (mutable) を MobX の observer を用いずに immutable な世界に持ち込んでしまうと、MobX 上は状態が更新されていても UI に反映されないなど意図しない挙動になります。
- 先行導入では範囲が限定的だったため問題になりませんでしたが、全体的に使っていくうえではこの線引が課題でした。
- immer と MobX の内部実装の衝突
- 既存コンポーネントとの連携
- MVVM における一般論として view model が view に依存するのはアンチパターンであり、view との連携には view model にコールバックなどを依存注入する形などが取られます。
- Henry の基本的な UI は design system のコンポーネントとして実装されており、それらを view model 側から使いやすくするために、対応する view model を用意したいと考えるようになりました。
これらの理由から MobX の導入範囲を限定的にするよりも、アプリケーション全体で採用した方が開発生産性は高くなると判断しました。
この意思決定をしたときの ADR の内容を一部抜粋して紹介します。
方針
ラベルは RFC 2119 に従う。
- [May] MobX は全体で使って良いものとする。
- [Should] 一定の複雑性がある部分については MobX を積極的に使う。
- [Must] 共通実装 (例: useForm) や design system の汎用コンポーネントは必ず MobX 版を用意する。
- [Should] 既存の interface を残し、MobX 以外からは従来通り使えるようにする。(例: MobX を wrap した hooks を提供するなど)
移行
- 基本的に使えるところから使い始めて良い。
- ただし共有実装・design system 側の変更が必要な部分に関しては慎重に取り組む。
- 特に useForm の移行を優先的に計画に載せたい。
- 別途、トップレベルで (MobX ベースの) 新規実装と既存実装を分離することを検討したい。
- ADR「フロントエンドを multi-package 構成にする」で決定する。
3. ドメインモデルも MobX で (現在)
現在では新規開発部分は 100%、MobX による View Model が作られるようになり、既存部分も徐々に MobX に移行しています。
最近は View Model に留まらず、フロントエンドで Domain Model の導入を積極的に進めています。 これまでフロントエンド開発では、GraphQL から取得した型定義を直接利用し、それらの immutable object に対してビジネスロジックを関数として実装していました。 例えば「外来カルテの医薬品」のドメインだけでもこのような関数が50個ほどあり、ドメインの理解を妨げる原因となっていました。
Before
- GraphQL の型定義とレスポンスの immutable object そのまま使用
- ビジネスロジックを immutable object に対する純粋関数として実装
- ビュー側にもビジネスロジックが点在
一方バックエンド開発では以前から Domain Model を活用してきた実績があり、フロントエンド開発でも同様に MobX のクラスとして Domain Model を実装することに挑戦しています。
After
- GraphQL の型定義を直接利用することを禁止
- Domain Model との相互変換を徹底
- ビジネスロジックを Domain Model に徹底的に集約
- 表示用の整形ロジックだけでなく、データの更新にまつわる制約や整合性保証も適切に管理
- ビュー側のロジックが最小限に
ここの知見はまた別のブログにできればと考えていますが、それまではカジュアル面談などで直接話を聞きにきてもらえると嬉しいです。
(補足: 関数型プログラミングの知見不足も一要因でしたが、より本質的には、ドメインを育てていくという意識と取り組みが不十分だったことが、これまでの課題だったと考えています。その上で MobX は View Model と Domain Model 両方で同じ技術を使えることにより、総体的な意識を高める効果があったように感じています)
新規開発のドメインモデルの実装例
*実際のコードから一部改変しています。
/** 処方オーダーのレシピ */ export class PrescriptionOrderRpModel { /** ID */ readonly id: string; /** 基準日 */ @observable accessor #baseDate: DateValue; /** 頓用指示か */ @observable accessor #asNeeded = false; /** 持参薬であるか */ @observable accessor #isBringing = false; /** タイミング */ @nested readonly medicationTiming = new MedicationTimingModel(); /** スケール指示か */ @observable accessor slidingScaleEnabled = false; /** 投与方法のフリーテキスト */ @observable accessor dosageText = ""; /** 投与日数 */ @observable accessor boundsDurationDays: number | null = null; /** 内用薬の頓用回数 */ @observable accessor expectedRepeatCount: number | null = null; /** 注射手技 */ @observable accessor injectionTechnique: InjectionTechnique | null = null; /** 指示一覧 */ @nested @observable accessor instructions: (MedicationDosageInstructionModel | EquipmentInstructionModel)[] = []; private constructor(args: Pick<PrescriptionOrderRpModel, "id" | "baseDate">) { ... makeValidatable(this, (b) => { if (this.instructions.length === 0) { b.invalidate("instructions", "1つ以上の指示を追加してください"); } else if (this.#allDosageFormTypes.size > 1) { b.invalidate("instructions", "異なる剤形の指示を同時に追加することはできません"); } else if (!this.hasMedicationDosageInstruction) { b.invalidate("instructions", "医薬品を追加してください"); } if (this.canSpecifyBoundsDurationDays && (this.boundsDurationDays === null || this.boundsDurationDays < 1)) { b.invalidate("boundsDurationDays", "1日以上を指定してください"); } if (this.canSpecifyExpectedRepeatCount && (this.expectedRepeatCount === null || this.expectedRepeatCount < 1)) { b.invalidate("expectedRepeatCount", "1回以上を指定してください"); } if (this.isInjection && !this.injectionTechnique) { b.invalidate("injectionTechnique", "手技を指定してください"); } }); makeValidatable(this.medicationTiming, (b) => { if (!this.medicationTiming.hasValue && !this.dosageText) { b.invalidate("canonicalPrescriptionUsage", "用法を指定してください"); } }); } ... /** 頓用指示か */ get asNeeded() { return this.#asNeeded; } /** 頓用指示か */ set asNeeded(value: boolean) { this.#asNeeded = value; this.#ensureDataConsistency({ isAsNeededChanged: true }); for (const inst of this.instructions) { if (inst instanceof MedicationDosageInstructionModel) { inst.asNeeded = value; } } } ... /** * 剤形 * * @returns 指示がない場合や、複数の剤形が混在する場合は null を返す。 */ @computed get dosageFormType() { if (this.#allDosageFormTypes.size === 1) { for (const value of this.#allDosageFormTypes) { return value; } } return null; } /** 内用薬か */ get isInternal() { return this.dosageFormType === DosageFormType.internal; } /** 外用薬か */ get isExternal() { return this.dosageFormType === DosageFormType.external; } /** 注射薬か */ get isInjection() { return this.dosageFormType === DosageFormType.injection; } /** 内用薬の頓用回数が指定可能か */ @computed get canSpecifyExpectedRepeatCount() { // 頓用の内用薬 return this.asNeeded && this.isInternal; } /** 投与日数が指定可能か */ @computed get canSpecifyBoundsDurationDays() { // 頓用でない内用薬 return !this.asNeeded && this.isInternal; } ... /** * 医薬品を追加する * * @returns 追加できた場合は true、互換性がなく追加できなかった場合は false. */ @action addMedication(medication: MedicationModel) { if (this.dosageFormType && medication.dosageFormType !== this.dosageFormType) { return false; } this.instructions.push( MedicationDosageInstructionModel.create({ medication, isInjectionSelfAdministered: true, asNeeded: this.asNeeded, isBringing: this.isBringing, baseDate: this.baseDate, }) ); this.#ensureDataConsistency(); return true; } ... }
まとめ
半年以上運用してみての感想をまとめます。
- 全体的にはかなりポジティブ
- ロジックの見通しが良くなり、開発生産性と品質が向上した。
- 特に複雑なドメインや GUI の実装において、コードの構造化と保守性が大きく改善した。
- MobX の API はシンプルだが細部は複雑
- 基本的な使用においては学習コストは低い。
- ただし、深く使いこなすためには MobX の詳細な挙動を理解しておくことを勧める。
- ドキュメントに明記されていない実装の詳細があり、時には実装を読む必要がある。
- Domain Model の実装に新たな可能性と課題
- MobX のクラスベースの設計は Domain Model の実装に適している。
- バックエンドとは異なる特有の課題 (動的な側面をどこまで Domain Model に持たせるかなど) があり、ベストプラクティスを模索中。
- 設計の重要性が一層明確に
- Domain Model, View Model を導入したことで、適切でない設計が目立つように。
- Domain Model の設計は本質的に難しく、より深い思考と設計力が必要。
複雑なドメインを扱うフロントエンド開発において、MobX は強力な武器となりました。
特に View Model による UI の構造化と、Domain Model によるドメインロジックの表現という2つの側面で、大きな価値を発揮しています。今後も実践を通じて知見を深め、より良い設計パターンを確立していきたいと考えています。