こんにちは。最近仕事であまり React 書いてない亀山です。休日に趣味で書いてる。
React で Function Component と Hooks を使うのがすっかり主流になりましたね。Hooks のおかげで HOC がやや下火になってきた一方で、MobX は useObserver が非推奨になって observer の HOC が推奨されるなど、やや流れとは異なる動きがありますね。しかし実際書いてみると、useObserver はコードの記述量もやや多く、使い方にややコツが必要だったりして、observer を使ったほうが良い開発体験が得られています。
ところで、MobX を使う際には、Redux などとは異なり複数の Store を作るのが一般的だと思いますが、UI 用の Store を作ると色々と良かったという話をします。
要約
- なるべくコンポーネント側で複雑な処理をさせない
- UI 用の Store を作って、他の Store に依存した computed プロパティを作る
- コンポーネント側では単にそのプロパティを参照するだけにする
サンプルアプリ
- 写真一覧を表示する Web アプリを作ります
- 写真一覧は API から取得します
Store の構成
- すべての Store を保持する RootStore
- データを管理する Store (例: PhotoStore)
- UI のための Store (例: PhotoViewStore)
プロジェクト準備
npx create-react-app mobx-react-example --template typescript --use-npm
npm install mobx mobx-react-lite
RootStore を作る
export class RootStore { }
Hooks を作る
グローバル変数でもいいんですが、僕は Context から取得するほうが好きなので、RootStore を注入するための Context、取得するための Hooks を用意します。
単に React の Provider を使っているだけなので、MobX に限った内容ではありません。
import { createContext, useContext } from "react" import RootStore from "./RootStore" export const StoreContext = createContext<RootStore>( (null as unknown) as RootStore ) export const useStores = () => useContext(StoreContext)
index.tsx でルートのコンポーネントを囲みます。
ReactDOM.render( <React.StrictMode> <StoreContext.Provider value={new RootStore()}> <App /> </StoreContext.Provider> </React.StrictMode>, document.getElementById('root') );
PhotoStore
API から写真一覧を取得し、photos プロパティとしてアクセスできるようにします。makeObservable で photos の変化が通知されるようにします。
import { makeObservable, observable } from "mobx" interface Photo { albumId: number id: number title: string url: string thumbnailUrl: string } export class PhotoStore { photos: Photo[] = [] constructor() { makeObservable(this, { photos: observable, }) } fetchPhotos() { fetch("https://jsonplaceholder.typicode.com/photos") .then(res => res.json()) .then((photos) => this.photos = photos) } }
import { PhotoStore } from "./PhotoStore"; export class RootStore { photoStore = new PhotoStore() }
写真一覧コンポーネント
写真一覧を表示するコンポーネントを実装します。
observer で囲ったコンポーネントは、obserevable にアクセスしていると、そのプロパティの変更時に自動的に再描画されるようになります。今回は photoStore.photos の変更が監視されます。
先程作った useStores から PhotoStore を取得し、photos を取得しています。
import { observer } from "mobx-react-lite"; import { FC, useEffect } from "react"; import { useStores } from "./useStores"; export const PhotoList: FC = observer(() => { const { photoStore } = useStores() return <div> {photoStore.photos.map(photo => <div> <p>{photo.title}</p> <img src={photo.thumbnailUrl} /> </div>)} </div> })
機能追加
表示する写真の数を数字入力する機能を追加することにします。
ひとまず、useState を使ってコンポーネント内で表示する写真の数の状態を管理します。この例ではさほど悪くないように見えます。
const PhotoList: FC = observer(() => { const { photoStore } = useStores() const [listSize, setListSize] = useState(10) return <div> <input type="number" value={listSize} onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => { setListSize(parseInt(e.target.value)) }, [])} /> {photoStore.photos.slice(0, listSize).map(photo => <div> <p>{photo.title}</p> <img src={photo.thumbnailUrl} /> </div>)} </div> })
突然の仕様変更
例えば listSize がユーザー設定としてサーバー等から取得されることになったらどうでしょうか。新しくユーザー設定を取得する UserSettingStore を追加します。
class UserSettingStore { photoListSize: number fetchSetting() { ... } updatePhotoListSize() { ... } }
コンポーネント側では、useState の代わりに UserSettingStore を見るように変更します。
const PhotoList: FC = observer(() => { const { photoStore, userSettingStore } = useStores() return <div> <input type="number" value={userSettingStore.listSize} onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => { userSettingStore.updatePhotoListSize(parseInt(e.target.value)) }, [])} /> {photoStore.photos.slice(0, userSettingStore.listSize).map(photo => <div> <p>{photo.title}</p> <img src={photo.thumbnailUrl} /> </div>)} </div> })
更に仕様変更
写真だけではなく、写真をアップロードしたユーザーの情報も表示することになって、サーバーから返ってくるデータを結合することになったらどうでしょうか。(サーバー側でなんとかしてくれと思うでしょうけど、そういうこともあるのです)
ユーザー情報を取得する UserStore を追加します。
class UserStore { users: User constructor() { makeObservable(this, { users: observable }) } fetchUsers() { ... } }
UserStore からユーザー一覧を取得し、PhotoStore の写真一覧のデータと結合して表示します。
interface PhotoWithUser extends Photo { userName: string|null userImageUrl: string|null } const PhotoList: FC = observer(() => { const { photoStore, userSettingStore, userStore } = useStores() const photos = photoStore.photos.map(photo => { const user = userStore.users.find(u => u.id === photo.userId) return { ...photo, userName: user?.name, userImageUrl: user?.imageUrl } }) return <div> <input type="number" value={userSettingStore.listSize} onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => { userSettingStore.updatePhotoListSize(parseInt(e.target.value)) }, [])} /> {photos.slice(0, userSettingStore.listSize).map(photo => <div> <p>{photo.title}</p> <img src={photo.thumbnailUrl} /> </div>)} </div> })
さてだんだん混沌としてきました。
UI 用の Store
ここでようやく本記事で紹介したかったものを書きます。
UI 用の Store を作ります。この Store はコンストラクタで他の Store を受け取り、そのプロパティを監視します。主に computed プロパティを提供するための存在となります。デザインパターン風に言うと、Facade パターン、サーバーサイドで言うところの BFF みたいな役割です。rootStore を保持していて循環参照になっていて気持ち悪い感じがしますが、Store はアプリケーションのライフサイクルを通して変化しないのでよいでしょう。
参考 https://mobx.js.org/defining-data-stores.html#combining-multiple-stores
import { computed, makeObservable, observable } from "mobx" import { Photo, PhotoStore } from "./PhotoStore" export class PhotoViewStore { private rootStore: RootStore constructor(rootStore: RootStore) { this.rootStore = rootStore makeObservable(this, { photos: computed, }) } get photos(): PhotoWithUser[] { return this.photoStore.photos.slice(0, this.rootStore.userSettingStore.photoListSize).map(photo => { const user = userStore.users.find(u => u.id === photo.userId) return { ...photo, userName: user?.name, userImageUrl: user?.imageUrl } }) } }
UI 用の Store を利用した例
PhotoList は基本的には photoViewStore のみを見ればよくなり、データの結合処理なども Store に移動するので、見通しがよくなりました。
export const PhotoList: FC = observer(() => { const { photoViewStore } = useStores() return <div> <input type="number" value={photoViewStore.listSize} onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => { photoViewStore.listSize = parseInt(e.target.value) }, [photoViewStore])} /> {photoViewStore.photos.map(photo => <div> <p>{photo.title}</p> <img src={photo.thumbnailUrl} /> </div>)} </div> })
何を Store に入れるか
コンポーネント内の useState で管理する状態をすべて UI 用の Store に移す必要はありませんが、observable に関係した状態は Store に移すと良いです。
例えば UI で選択中の写真を表示するという機能を作ったとき、photos から選択中の写真を find することになるので、これは Store に移します。
export const PhotoList: FC = observer(() => { const { photoViewStore } = useStores() return <div> <input type="number" value={photoViewStore.listSize} onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => { photoViewStore.listSize = parseInt(e.target.value) }, [photoViewStore])} /> <div> <h2>選択中の写真</h2> <div><img src={photoViewStore.selectedPhoto.thumbnailUrl} /></div> </div> {photoViewStore.photos.map(photo => <div> <p>{photo.title}</p> <img src={photo.thumbnailUrl} /> </div>)} </div> })
選択中の写真の ID を管理する selectedPhotoId と、computed の selectedPhoto を追加します。
export class PhotoViewStore { private rootStore: RootStore selectedPhotoId: number = -1 constructor(rootStore: RootStore) { this.rootStore = rootStore makeObservable(this, { photos: computed, selectedPhoto: computed, selectedPhotoId: observable, }) } get photos(): PhotoWithUser[] { return this.photoStore.photos.slice(0, this.rootStore.userSettingStore.photoListSize).map(photo => { const user = userStore.users.find(u => u.id === photo.userId) return { ...photo, userName: user?.name, userImageUrl: user?.imageUrl } }) } get selectedPhoto(): PhotoWithUser|undefined { return this.photos.find(photo => photo.id === selectedPhotoId) } }
わかりやすいですね。
コンポーネント用の Store を作る利点
- コンポーネント側にロジックが入り込まないため、見通しの良いコードになる
- データをやりとりする Store が表示上の都合で変更されることなく、データの取得に専念できる
- コンポーネントが監視するプロパティが減るため、パフォーマンスの最適化がしやすくなる
- 例えば今回は PhotoViewStore.photos が余計に更新されていないかだけを MobX Developer Tools などで確認すれば済みますが、複数の Store を見ている場合は再描画される原因が増えて調べづらくなります
- テストも書きやすいです
使い所
なんでもかんでもコンポーネントに対応した Store を作ると過剰なアーキテクチャになってしまうため、ある程度まとまった単位で作るとよいでしょう。例えばページ単位に作るとわかりやすいです。