Help us understand the problem. What is going on with this article?

React MobX における SingletonStoresの考察

More than 3 years have passed since last update.

先日の投稿に続き第4弾です。MobXは柔軟で分かり易いため、多くを語らずともだいたいのことは出来てしまいます。

MobXはObservableな値を導入するためのライブラリであり、フレームワークではありません。実装がシンプルになる一方、設計方針と規約を明確にしなければ、独自設計が入り乱れ、保守の難しいコードになってしまいます。既出の設計論をみつつ、考慮出来るポイントをいくつか紹介していきます。

既存の React MobX 設計論

React-MobXにおけるコンポーネント実装の正は明確で、迷うことはほぼありません。小さく組み立てていき、あとは stateless か stateful かを選択するぐらいでしょう。MobXにおいて設計のキモは Store に集約するものだとすぐに気付きます。mobx.js.org でも Store設計がどうあるべきか、という記事が掲載されています。https://mobx.js.org/best/store.html

ここに記されている Singletonパターンは冒頭で明記されている通り、単なる1つの方法であり、MobX = Singleton ではないことを誤解なきようお願いします。

Singletonパターン考察

class定義ファイル内でインスタンスを1つ生成して export、class定義は exportしない。こうすることで、アプリケーションにおいて唯一のインスタンスが約束されます。以下の様な実装です。

stores/singletons/WindowStore.js
class WindowStore {
}
const singleton = new WindowStore()
export default singleton

ウィンドウリサイズで observable値を変更するものに書き換えてみましょう。幅と高さが observableになります。

stores/singletons/WindowStore.js
class WindowStore {
  @observable width
  @observable height
  constructor() {
    window.addEventListener('resize', e => this.onResize(e))
  }
  @action onResize = () => {
    this.width  = window.innerWidth
    this.height = window.innerHeight
  }
}
const singleton = new WindowStore()
export default singleton

ここで定義されている observable値を参照している異なる Storeをみてみましょう。computedで反応することが出来ます。

stores/singletons/GlobalUIStore.js
import WindowStore from '~/stores/singletons/WindowStore.js'

export default class GlobalUIStore {
  @computed get windowArea() {
    return `${WindowStore.width * WindowStore.height}px`
  }
}
const singleton = new GlobalUIStore()
export default singleton

ただし、別Storeに密になりすぎたり、いくつものStoreを跨ぐ参照は控えた方が良いので、それぞれの役割を明確にしましょう。また、このままではテストコードが必要な場合に困ります。

参照しあうStoreの設計

先ほどのサンプルコードでは、画面サイズのモックを作成することが不可能でした。この点、少し工夫をすることでクリアすることが出来ます。

stores/singletons/WindowStore.js
export class WindowStore { // ※1 exportしてしまう
  @observable width
  @observable height
  constructor(props) {
    Object.assign(this, props) // ※2 instantiate時に引数をとる
    window.addEventListener('resize', e => this.onResize(e))
  }
  @action onResize = () => {
    this.width  = window.innerWidth
    this.height = window.innerHeight
  }
}
const singleton = new WindowStore()
export default singleton // ※3 通常 importされるのはこっち
stores/singletons/GlobalUIStore.js
// ※4 シングルトンインスタンスを import
import WindowStore from '~/stores/singletons/WindowStore.js' 

export class GlobalUIStore {
  constructor(props) {
    Object.assign(this, props) // ※6 instantiate時に引数をとる
  }
  @computed get windowArea() {
    // ※7 この段階ではシングルトンインスタンスを参照
    return `${this.WindowStore.width * this.WindowStore.height}px`
  }
}
// ※5 依存Store(シングルトンインスタンス)への参照を保持させる。(ブラウザ実行時の挙動)
const singleton = new GlobalUIStore({ WindowStore })
export default singleton
stores/singletons/GlobalUIStore.test.js
// ※8 それぞれシングルトンインスタンスではなく class定義を import
import { WindowStore }   from '~/stores/singletons/WindowStore.js' 
import { GlobalUIStore } from '~/stores/singletons/GlobalUIStore.js' 

const globalUIStore = new GlobalUIStore({
  // ※9 モックを挿入することが出来る様になった
  WindowStore: new WindowStore({ width: 640, height: 480 })
})
// ...中略
it('Find screen area', () => {
  expect(globalUIStore.windowArea).to.be.eql("307200px")
})

class定義をexportしてしまっている段階で、シングルトンパターンとしては破綻していますが、ここは規約に定めて矯正する他なさそうです。Store同士の依存関係をコンストラクタで渡すことで、テスト可能なStoreにすることが出来ました。

SingletonStore の inject

mobx-react の API である inject は、紐づけられた Providerが保持している Storeに限った参照をできるものだと思っていましたが、今のところそうでは無い様子です。SingletonStoreインスタンス を component で直接 inject しても反応するので、設計次第では活用出来るかもしれません。

someComponent.js
import { inject } from 'mobx-react'
import SomeStore  from '~/stores/singletons/someStore'

const SomeComponent = ({ valueA, valueB, flag }) => {
  return (
    <div className="some-component">
      { flag ? valueA : valueB }
    </div>
  )
}

export default inject(() => ({
  valueA: SomeStore.observableValueA,
  valueB: SomeStore.observableValueB,
  flag:   SomeStore.observableFlag
}))(observer(SomeComponent))

SingletonStore の instantiate順

これまでのサンプルコードの場合、SingletonStore を importした時点で初めてインスタンスが生成されます。そのため、参照関係が前後してしまうと、インスタンス生成前に参照エラーが発生します。SingletonStoreは一元管理し、エントリーポイントに近い位置で import しておくと良いです。

これまでのサンプルコードを、最終的なものに書き換えると以下の様になります。

entry_point.js
import '~/stores/singletons'
stores/singletons.js
import _SettingsStore  from '~/stores/singletons/settingsStore'
import _WindowStore    from '~/stores/singletons/windowStore'
import _SomeStore      from '~/stores/singletons/someStore'

export const SettingsStore = new _SettingsStore()
export const WindowStore   = new _WindowStore({ SettingsStore })
export const SomeStore     = new _SomeStore({ SettingsStore, WindowStore })
stores/singletons/windowStore.js
export default class WindowStore {
  @observable width
  @observable height
  constructor(props) {
    Object.assign(this, props)
    window.addEventListener('resize', e => this.onResize(e))
  }
  @action onResize = () => {
    this.width  = window.innerWidth
    this.height = window.innerHeight
  }
}
// const singleton = new WindowStore() // ※各ファイルでは、インスタンスを生成しない
// export default singleton

Takepepe
Web Application Developer. interested in TypeScript AST.
http://design.dena.com/
dena_coltd
    Delight and Impact the World
https://dena.com/jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした