Next.jsでのMobXの使用方法について

この記事は、Next.js Advent Calendar 2019 24日目の記事です。

Next.jsでMobXを使用するには以下の問題を解決する必要があります

1. `getInitialProps`からStoreへアクセスできない :no_good_tone1: 
  - よってSSR時にAPIからのレスポンス値をStoreに格納できない
1. SSR側でStoreに値を入れてもクライアント側に反映されない :no_good_tone1: 
  - よってクライアント側で値の再取得になる

どちらもSSR特有の問題でReduxの場合は `next-redux-wrapper` なるものがあり解決できるが、MobXには良さそうなパッケージはありません。
実は`next-mobx-wrapper` というのパッケージがありますがインストール数も少なくメンテされているようにも見えなかったので自作してみました。

## withMobx

何を作ったかというと`next-redux-wrapper` を模したMobX版のHOCラッパーの`withMobx`。
先に使い方を見ていきます。

### 使い方

例えば以下のようなStoreがあったとします

```typescript
class AppStore {
  @observable myName: string = ''
  constructor(init: Partial<AppStore>) {
    Object.assign(this, init)
  }
  @action setMyName = (name: string) => {
    this.myName = name
  }
}
```

RootStoreは即にnewするのではなく、下の`makeStore`のように一度関数を挟みます

```typescript
type Store = { app: AppStore }

const makeStore = (state: Partial<Store> = {}): Store => {
  return {
    app: new AppStore(state.ui)
  }
}
```

今回のHOCの仕様上上のようにする必要があるので注意です


#### _app.jsに`withMobx` を反映

使い方はとてもシンプルで以下のように最後に`makeStore`を渡しつつ挟むだけです。
そして`Prop`から渡ってくる`Store`を`Provider`へ渡します

```typescript
import React from 'react'
import App from 'next/app'
import { Provider } from 'mobx-react'
import { withMobx } from '~/helpers/withMobx'
import makeStore, { Store } from '~/stores'

type Props = {
  store: Store
}

class MyApp extends App<Props> {
  render() {
    const { Component, pageProps, store } = this.props

    return (
      <Provider {...store}>
        <Component {...pageProps} />
      </Provider>
    )
  }
}

export default withMobx(makeStore)(MyApp)
```

こうすることによって`pages/`の`getInitialProps`からStoreにアクセスができます

```typescript
// pages/index.tsx

import React from 'react'
import { NextPage } from 'next'
import { NextJSContext } from '~/helpers/withMobx'

const LoginPage: NextPage<{ myName: string }> = ({ myName }) => {
  return <div>{myName}</div>
}
LoginPage.getInitialProps = async (ctx: NextJSContext) => {
  const { app } = ctx.store // <= Storeをゲット！
  const myName = await fetchMyName()
  app.setMyName(myName)
  return { myName }
}

export default LoginPage
```

### withMobxの中身

中身は以下の通りです。使いたい場合はこちらをコピペなりして好きに書き換えてもらって構いません
(本当はパッケージとして公開するつもりでしたが間に合いませんでした :bow: ）

```typescript
import React fom 'react'
import { AppContext } from 'next/app'
import { NextComponentType, NextPageContext } from 'next'

export const withMobx = (makeStore: MakeStore) => {
  const isServer = typeof window === 'undefined'

  const initStore = (initialState?: any) => {
    const storeKey = '__NEXT_MOBX_STORE__'
    const createStore = () => {
      return makeStore(initialState)
    }

    if (isServer) return createStore()

    if (!(storeKey in window)) {
      (window as any)[storeKey] = createStore()
    }

    return (window as any)[storeKey]
  }

  return (App: NextComponentType | any) =>
    class WrappedApp extends React.Component<WrappedAppProps> {
      public static displayName = `withMobx(${App.displayName || App.name || 'App'})`;

      public static getInitialProps = async (appContext: NextJSAppContext) => {
        const store = initStore()
        appContext.ctx.store = store
        appContext.ctx.isServer = isServer

        let initialProps = {}

        if ('getInitialProps' in App) {
          initialProps = await App.getInitialProps.call(App, appContext)
        }

        return {
          initialState: store,
          initialProps,
        }
      }

      public constructor(props: WrappedAppProps) {
        super(props)
        const { initialState } = props
        this.store = initStore(initialState)
      }

      protected store: any;

      public render() {
        const { initialProps, initialState, ...props } = this.props
        return (
          <App {...props} {...initialProps} store={this.store} />
        )
      }
    }
}

export type NextJSContext<Store = any> = NextPageContext & {
  store: Store
  isServer: boolean
}

export type NextJSAppContext = AppContext & {
  ctx: NextJSContext;
}

export type WrappedAppProps = {
  initialState: any
  initialProps: any
}

export type MakeStore = (initialState: any) => any
```

### 内部で何が行われているのか

(ここからは読み飛ばしてもらってOK)
このラッパーの中では以下の事が行われています

#### SSR側

1. `getInitialProps`内
  1. Storeの生成
  1. Storeを`appContext`へ追加 (すべての`getInitialProps`へ渡すため)
  1. `initialState`としてpropsへStoreを渡す (
  1. `__NEXT_DATA__` というidがついた`script`タグにJSONのPropsデータが格納されレンダリングされる
1. インスタンス内
  1. `getInitialProps`から渡ってきたstateを元にStoreの生成
  1. AppコンポーネントにStoreを渡す

#### Client側

1. `__NEXT_DATA__` 内のJSONをPropsとして受け取る
1. グローバルにStoreが存在しているか確認
1. なければ `__NEXT_DATA__` を元にStore生成、あればそれを使う（ページ遷移時に都度Storeが生成されるのを防ぐため)

以上が大まかな流れです。

## まとめ

`withMobx` を使用することで難しいSSRを考慮した処理が隠蔽されコードがすっきりしました。実はもう一段階tokenの取り回しという壁があるのですがそれはまた別記事に書こうと思います。