0.はじめに
JavaScriptの「シンプルかつスケーラブルな」状態管理ライブラリことMobXをReactと結びつけて、楽しくWebアプリケーションを作れるようになってみたいと思いませんか?
当記事ではReactとMobXを組み合わせて使うためのライブラリmobx-react-liteを使って、観測可能な状態と観測者による状態管理を俯瞰してみたいと思います。
前編では、mobx-react-liteで提供されるObserverを紹介します。
環境と想定読者
node.jsのインストールが必要です。node.jsを使うならば、備え付けのパッケージマネージャのnpm
について詳しく知る必要があります。しかし、当記事ではパッケージマネージャとしてYarnを用います。yarnは以下からダウンロードできます。
MobXの前に、軽くReactに関する知識が必要です。
- Hello World|React を確認しておきましょう。JSXを知り、関数型コンポーネントが書けるようになればオッケーです。
- 今回のチュートリアルでは、関数型コンポーネントとHooksをたくさん書くので、以下の大変参考になるQiita記事を目を通しておくかもいいかもしれません。なお、本チュートリアルにおいてはHooksは、都度説明を入れるつもりです。React 16.8: 正式版となったReact Hooksを今さら総ざらいする|Qiita by uhyo
1.準備
Next.js 9を使って、今回のチュートリアルの環境を作っていきましょう。
パッケージマネージャと依存関係のインストール
作業用ディレクトリを作成し、そこで以下のコマンドを実行することで依存関係(パッケージ)をインストールします。
yarn add next@latest react@latest react-dom@latest mobx mobx-react-lite
執筆当時のpackage.json
{
"dependencies": {
"mobx": "^5.11.0",
"mobx-react-lite": "^1.4.1",
"next": "^9.0.1",
"react": "^16.8.6",
"react-dom": "^16.8.6"
}
}
開発サーバの立ち上げ
pages
という名前のディレクトリを作成し、その中にindex.jsx
というファイルを作成します。
const Index = () => <p>It Works!</p>;
export default Index;
そして、以下のコマンドを実行すると開発サーバが立ち上がります。
yarn next
そして、http://localhost:3000 にアクセスした時に以下のように表示されていたら成功です。
開発サーバーはターミナルでctr+c
でを打てば終了します。
Next.jsの基本
Next.js
はpages/
配下の.jsx
ファイルなどでReact
のコンポーネントをexport default
すると、そのディレクトリ名に対応するページが新規作成されます。
また、開発サーバーにおけるNext.js
はファイルの更新を検出し、サーバーを再起動することなく更新を反映させます。
次のチュートリアルの準備のために、pages/counter.jsx
を作成して中をこのようにします。
const CounterPage = () => {
return (
<div>
<p>ここはカウンターページです</p>
<hr/>
</div>
);
};
export default CounterPage;
すると先ほどの説明のように、counterというページがブラウザで読み込めるようになります。
http://localhost:3000/counter
2.Hello MobX
MobXの観測可能な状態と観測者を早速使ってみましょう。
import {Observer, useLocalStore} from "mobx-react-lite"; //追加
const CounterPage = () => {
/***
* 観測可能な状態
* ストア:{counter: number}のように扱える
*/
const store = useLocalStore(() => ({counter: 0}));
/***
* ストアの操作のための関数
* ボタンに与える
*/
function increment() {
store.counter++;
}
function decrement() {
store.counter--;
}
return (
<div>
<p>ここはカウンターページです</p>
<hr/>
{/***
* 観測者コンポーネント
* 観測可能な状態の変化に応じて更新される
*/}
<Observer>{() => (<p>{store.counter}</p>)}</Observer>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
};
export default CounterPage;
これで、+を押せばカウントアップされ、-を押せばカウントダウンするページが作れました。
解説
観測可能な状態
mobx-react-lite
ではuseLocalStore
Hookを使えば、観測可能な状態を作ることができます。観測可能な状態の作り方は他にもあります。
useLocalStore(() => {return オブジェクト}); // これでオブジェクト型のObservableステートが作れる
useLocalStore
はHookですので、二つのルールがあります。
フックは JavaScript の関数ですが、2 つの追加のルールがあります。
- フックは関数のトップレベルのみで呼び出してください。ループや条件分岐やネストした関数の中でフックを呼び出さないでください。
- フックは React の関数コンポーネントの内部のみで呼び出してください。通常の JavaScript 関数内では呼び出さないでください(ただしフックを呼び出していい場所がもう 1 カ所だけあります — 自分のカスタムフックの中です。これについてはすぐ後で学びます)。
したがって、クラス型のコンポーネントからはuseLocalStore
Hookは使えません。
観測者
mobx-react-lite
では観測者Reactコンポーネントを作ることができます。Observerコンポーネント
は、子要素のようにReactのコンポーネントを書くことができず、render関数を渡してやる必要があります。つまりObserverコンポーネント
を使う際は以下の形式になることが多いでしょう。
<Observer>{() => (観測可能な状態にアクセスするReact要素)}</Observer>
観測可能な状態へのアクセスとは、短絡的な話だと観測可能な状態.観測可能な状態のメンバー
における.
を含むということです。
3. mobx-react-liteで提供される観測者(HOC, Observerコンポーネント, useObserver)
ちょっと準備:Hooksやコンポーネントを使い回せるようにする
先ほどのチュートリアルでuseLocalStore
によるカウンターのストアを作りました。これを使い回せるようにカスタムHookを作成しましょう。
import { useLocalStore } from "mobx-react-lite";
export function useCounterStore() {
const store = useLocalStore(() => ({
counter: 0,
increment: () => {
store.counter++;
},
decrement: () => {
store.counter--;
}
}));
return store;
}
incrementや、decrementをStoreの中に入れてしまうことで、取り回しが良くなります。
次にボタンもコンポーネントにしましょう。
export const CounterButton = props => (
<>
<button onClick={props.store.increment}>+</button>
<button onClick={props.store.decrement}>-</button>
</>
);
ここまででファイル構成はこのようになっています。
.
├── components
│ └── counterButton.jsx
├── hooks
│ └── useCounterStore.js
├── package.json
├── pages
│ ├── counter.jsx
│ └── index.jsx
└── yarn.lock
Observerコンポーネント
Observerコンポーネントは最もよく使う観測者でしょう。使い方はすでに見た通りです。
pages/observer.jsxの全体
import { Observer } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.counter}</p>;
const CounterPage = () => {
const store = useCounterStore();
return (
<div>
<p>オブザーバーコンポーネントの例</p>
<hr />
<Observer>{() => <Counter counter={store.counter} />}</Observer>
<CounterButton store={store} />
</div>
);
};
export default CounterPage;
Observerコンポーネントでハマるとすればrender propsです。
Observerコンポーネントが動かない例
Observerコンポーネントは、直下のrender関数の更新をすることができますが、render関数中でさらにrender関数を呼び出された場合、子のrender関数の更新をすることができません。したがって以下のような例ではカウンターが動きません。
動かない例
<Observer>
{() => (
<Ueshita
render={props => (
<>
ここは{props.name}
<Counter counter={store.counter} />
</>
)}
>
{props => (
<>
ここは{props.name}
<Counter counter={store.counter} />
</>
)}
</Ueshita>
)}
</Observer>
動く例
<Ueshita
render={props => (
<>
ここは{props.name}
<Observer>{() => <Counter counter={store.counter} />}</Observer>
</>
)}
>
{props => (
<>
ここは{props.name}
<Observer>{() => <Counter counter={store.counter} />}</Observer>
</>
)}
</Ueshita>
pages/observer2.jsxの全体
import { Observer } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.counter}</p>;
const Ueshita = props => (
<div>
{props.render({ name: "上" })}
<hr />
{props.children({ name: "下" })}
</div>
);
const CounterPage = () => {
const store = useCounterStore();
return (
<div>
<p>オブザーバーHOCの例</p>
<hr />
<h2>動かない</h2>
<Observer>
{() => (
<Ueshita
render={props => (
<>
ここは{props.name}
<Counter counter={store.counter} />
</>
)}
>
{props => (
<>
ここは{props.name}
<Counter counter={store.counter} />
</>
)}
</Ueshita>
)}
</Observer>
<hr />
<h2>動く</h2>
<Ueshita
render={props => (
<>
ここは{props.name}
<Observer>{() => <Counter counter={store.counter} />}</Observer>
</>
)}
>
{props => (
<>
ここは{props.name}
<Observer>{() => <Counter counter={store.counter} />}</Observer>
</>
)}
</Ueshita>
<CounterButton store={store} />
</div>
);
};
export default CounterPage;
参考: https://mobx-react.netlify.com/observer-component
observer HOC
HOCはコンポーネントを引数として、コンポーネントを返す関数です。mobx-react-liteには、ただのコンポーネントを受け取って、それを観測者にするobserver
というHOCがあります。先ほど出てきたObserver
は先頭が大文字です。注意してください。
import { observer } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.store.counter}</p>;
const HOCCounter = observer(Counter);
const CounterPage = () => {
const store = useCounterStore();
return (
<div>
<p>オブザーバーHOCの例</p>
<HOCCounter store={store}/>
<hr />
<CounterButton store={store} />
</div>
);
};
export default CounterPage;
これは、先ほどの例と同じようにカウンターとして機能します。
落とし穴: observerが機能しない
ちょっと待ってください。 この例のCounterコンポーネントのpropsの取り方が少し冗長なように見えます。この機能を実装するならばいちいちstoreを渡さなくても良さそうに思えますね。つまりこちらの方が汎用性が高いコンポーネントでしょう。
const CounterMod = props => <p>{props.counter}</p>;
これをobserver HOCに繋ぎます。
const HOCCounterMod = observer(CounterMod);
そして表示してみましょう。比較用に、Observerコンポーネントに直繋ぎする例も見てみます。
<>
カウンターModを直にオブサーバーにつなぐ例
<Observer>{() => <CounterMod counter={store.counter} />}</Observer>
単にHOCで繋いだ例
<HOCCounterMod counter={store.counter}/>
</>
pages/hoc.jsxの全体
import { observer, Observer } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.store.counter}</p>;
const HOCCounter = observer(Counter);
const CounterMod = props => <p>{props.counter}</p>;
const HOCCounterMod = observer(CounterMod);
const CounterPage = () => {
const store = useCounterStore();
return (
<div>
<p>オブザーバーHOCの例</p>
<HOCCounter store={store} />
<hr />
カウンターModを直にオブサーバーにつなぐ例
<Observer>{() => <CounterMod counter={store.counter} />}</Observer>
単にHOCで繋いだ例
<HOCCounterMod counter={store.counter}/>
<CounterButton store={store} />
</div>
);
};
export default CounterPage;
なんと、動かない例が出てしまいました!単にHOCに繋いだものが更新されないのです。
これを動く例にしてみましょう。以下の二つを追加してみます
const HOCCounterModFixed = observer(props => <CounterMod counter={props.store.counter}/>);
<>
StoreからアクセスするようにHOCで繋いだ例
<HOCCounterModFixed store={store}/>
</>
pages/hoc.jsxの全体
import { observer, Observer } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.store.counter}</p>;
const HOCCounter = observer(Counter);
const CounterMod = props => <p>{props.counter}</p>;
const HOCCounterMod = observer(CounterMod);
const HOCCounterModFixed = observer(props => <CounterMod counter={props.store.counter}/>);
const CounterPage = () => {
const store = useCounterStore();
return (
<div>
<p>オブザーバーHOCの例</p>
<HOCCounter store={store} />
<hr />
カウンターModを直にオブサーバーにつなぐ例
<Observer>{() => <CounterMod counter={store.counter} />}</Observer>
単にHOCで繋いだ例
<HOCCounterMod counter={store.counter}/>
StoreからアクセするようにHOCで繋いだ例
<HOCCounterModFixed store={store}/>
<CounterButton store={store} />
</div>
);
};
export default CounterPage;
これで予想通りに動くようになりました。
落とし穴の理由
MobXにおいて観測者が追跡しているものは観測可能な状態へのアクセスであり、シンプルな言い方をすれば観測可能な状態.観測可能な状態のメンバー
における.
を追っているのです。
const CounterMod = props => <p>{props.counter}</p>;
const HOCCounterMod = observer(CounterMod);
つまり
const HOCCounterMod = observer(props => <p>{props.counter}</p>);
を
<HOCCounterMod counter={store.counter}/>
と使ったところで、observer
の引数の中でstore.counter
の.
が見えていません。
MobXは観測可能な状態のメンバーそのもの、つまり値そのものの変化に対して反応することはできません。
したがってobserver
HOCにおいては、propsで観測可能な状態を渡すようにしましょう。観測可能な状態のメンバーをコピーしたものや、観測可能な状態にアクセスした後の値を渡しても、表示は更新されません。
つまりHOCオブジェクトにおいてはJSXの要素の中で.
があってもダメなのです。
<HOCCounterMod counter={store.counter}/>
この仕様のため、慣れてくると多くの場合でObserver
コンポーネントを使うことが一番都合が良いと思うようになってきます。
つまり、以下の内容はちゃんと値の変化に応じて描画されます。
<Observer>{() => <HOCCounterMod counter={store.counter}/> />}</Observer>
こんなことをするのならば、observer HOCを使った意味がありませんね。
とはいえ、observer HOCを使えばこのようなことができます。
import { observer } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.counter}</p>;
const CounterPage = observer(() => {
const store = useCounterStore();
return (
<div>
<p>オブザーバーHOCの例2</p>
<hr />
<Counter counter={store.counter} />
<CounterButton store={store} />
</div>
);
});
export default CounterPage;
これはちゃんと動きます。しかし再描画の範囲がCounterだけではなく、CounterPage全体ということに注意をしてください。
やたらobserver HOCをディスリましたが、使い用はあるはずです。
でも、もう一つディスりポイントがあって、observer HOCはReactのLegacy Contextに依存しています。その点でもobserver HOCは気味が悪いですね。
参考: https://mobx-react.netlify.com/observer-hoc
useObserver Hook
useObserver Hookは前述の二つの観測者で内部的に利用されているReact Hookです。observer HOCの代わりのような使い方ができます。
実際に現在のObserverコンポーネントの実装(TypeScript)は以下のようになっています。
function ObserverComponent({ children, render }: IObserverProps) {
const component = children || render
if (typeof component !== "function") {
return null
}
return useObserver(component)
}
useObserverはmobx-react-liteの心臓部と言って差し支えないでしょう。
参考: https://github.com/mobxjs/mobx-react-lite/blob/master/src/ObserverComponent.ts
useObserver
をobserver
HOCのように使いたければ以下のようにしましょう。
import { useObserver } from "mobx-react-lite";
import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";
const Counter = props => <p>{props.counter}</p>;
const HookCounter = props => {
//普通はこれをそのままreturnでオッケー:Hookっぽさを出すためにこうした。
const Component = useObserver(() => (
<Counter counter={props.store.counter} />
));
return Component;
};
const CounterPage = () => {
const store = useCounterStore();
return (
<div>
<h2>オブザーバーHOOKの例</h2>
<HookCounter store={store} />
<hr />
<CounterButton store={store} />
</div>
);
};
export default CounterPage;
まとめ
- 基本はObserverコンポーネントを利用しましょう。
- 観測者が動かない時は、観測可能な状態からのアクセスを観測できているかチェックする。
- Render Propsで動かない時は、Observerをrender関数の中に入れる