MobX + React で UI 用の Store を作ると良いよ

こんにちは。最近仕事であまり 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 を保持していて循環参照になっていて気持ち悪い感じがしますが、アプリケーションのライフサイクルを通して変化しないのでよいでしょう。

参考 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 を作ると過剰なアーキテクチャになってしまうため、ある程度まとまった単位で作るとよいでしょう。例えばページ単位に作るとわかりやすいです。