9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

mobx-react-lite入門 前編: mobx-react-liteのObserver

Last updated at Posted at 2019-07-13

0.はじめに

JavaScriptの「シンプルかつスケーラブルな」状態管理ライブラリことMobXReactと結びつけて、楽しくWebアプリケーションを作れるようになってみたいと思いませんか?

当記事ではReactとMobXを組み合わせて使うためのライブラリmobx-react-liteを使って、観測可能な状態観測者による状態管理を俯瞰してみたいと思います。

前編では、mobx-react-liteで提供されるObserverを紹介します。

筆者の環境
  • OS: macOS Mojave 10.14.5
  • ブラウザ: Safari バージョン12.1.1
  • Node.js: v10.16.0
  • Yarn: 1.15.2

環境と想定読者

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を使って、今回のチュートリアルの環境を作っていきましょう。

パッケージマネージャと依存関係のインストール

作業用ディレクトリを作成し、そこで以下のコマンドを実行することで依存関係(パッケージ)をインストールします。

terminal
yarn add next@latest react@latest react-dom@latest mobx mobx-react-lite
執筆当時のpackage.json
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というファイルを作成します。

pages/index.jsx
const Index = () => <p>It Works!</p>;

export default Index;

そして、以下のコマンドを実行すると開発サーバが立ち上がります。

terminal
yarn next

そして、http://localhost:3000 にアクセスした時に以下のように表示されていたら成功です。

スクリーンショット 2019-07-13 3.11.53.png

開発サーバーはターミナルでctr+cでを打てば終了します。

Next.jsの基本

Next.jspages/配下の.jsxファイルなどでReactのコンポーネントをexport defaultすると、そのディレクトリ名に対応するページが新規作成されます。

また、開発サーバーにおけるNext.jsはファイルの更新を検出し、サーバーを再起動することなく更新を反映させます。

次のチュートリアルの準備のために、pages/counter.jsxを作成して中をこのようにします。

pages/counter.jsx
const CounterPage = () => {
    return (
        <div>
            <p>ここはカウンターページです</p>
            <hr/>
        </div>
    );
};

export default CounterPage;

すると先ほどの説明のように、counterというページがブラウザで読み込めるようになります。
http://localhost:3000/counter
スクリーンショット 2019-07-13 3.24.13.png

2.Hello MobX

MobXの観測可能な状態観測者を早速使ってみましょう。

pages/counter.jsx
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;

http://localhost:3000/counter
スクリーンショット 2019-07-13 3.53.44.png

これで、+を押せばカウントアップされ、-を押せばカウントダウンするページが作れました。

解説

観測可能な状態

mobx-react-liteではuseLocalStore Hookを使えば、観測可能な状態を作ることができます。観測可能な状態の作り方は他にもあります。

useLocalStore(() => {return オブジェクト}); // これでオブジェクト型のObservableステートが作れる

useLocalStoreはHookですので、二つのルールがあります。

フックは JavaScript の関数ですが、2 つの追加のルールがあります。

  • フックは関数のトップレベルのみで呼び出してください。ループや条件分岐やネストした関数の中でフックを呼び出さないでください。
  • フックは React の関数コンポーネントの内部のみで呼び出してください。通常の JavaScript 関数内では呼び出さないでください(ただしフックを呼び出していい場所がもう 1 カ所だけあります — 自分のカスタムフックの中です。これについてはすぐ後で学びます)。

フック早わかり|React

したがって、クラス型のコンポーネントからはuseLocalStore Hookは使えません。

観測者

mobx-react-liteでは観測者Reactコンポーネントを作ることができます。Observerコンポーネントは、子要素のようにReactのコンポーネントを書くことができず、render関数を渡してやる必要があります。つまりObserverコンポーネントを使う際は以下の形式になることが多いでしょう。

<Observer>{() => (観測可能な状態にアクセスするReact要素)}</Observer>

観測可能な状態へのアクセスとは、短絡的な話だと観測可能な状態.観測可能な状態のメンバーにおける.を含むということです。

3. mobx-react-liteで提供される観測者(HOC, Observerコンポーネント, useObserver)

ちょっと準備:Hooksやコンポーネントを使い回せるようにする

先ほどのチュートリアルでuseLocalStoreによるカウンターのストアを作りました。これを使い回せるようにカスタムHookを作成しましょう。

hooks/counterStore.js
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の中に入れてしまうことで、取り回しが良くなります。

次にボタンもコンポーネントにしましょう。

components/counterButton.jsx
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の全体
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の全体
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;

スクリーンショット 2019-07-13 21.23.56.png

参考: https://mobx-react.netlify.com/observer-component

observer HOC

HOCはコンポーネントを引数として、コンポーネントを返す関数です。mobx-react-liteには、ただのコンポーネントを受け取って、それを観測者にするobserverというHOCがあります。先ほど出てきたObserverは先頭が大文字です。注意してください。

pages/hoc.jsx
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;

スクリーンショット 2019-07-13 19.23.49.png

これは、先ほどの例と同じようにカウンターとして機能します。

落とし穴: 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の全体
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;

スクリーンショット 2019-07-13 19.31.06.png

なんと、動かない例が出てしまいました!単にHOCに繋いだものが更新されないのです。

これを動く例にしてみましょう。以下の二つを追加してみます

const HOCCounterModFixed = observer(props => <CounterMod counter={props.store.counter}/>);
<>
StoreからアクセスするようにHOCで繋いだ例
<HOCCounterModFixed store={store}/>
</>
pages/hoc.jsxの全体
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;

スクリーンショット 2019-07-13 19.35.52.png

これで予想通りに動くようになりました。

落とし穴の理由

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を使えばこのようなことができます。

pages/hoc2.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 = 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

useObserverobserver HOCのように使いたければ以下のようにしましょう。

pages/useobserver.jsx
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関数の中に入れる
9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?