なる早でMobXで作ったTodoリストをmobx-persistでサクッと永続化する

# 概要

[mobx-persist](https://github.com/pinqy520/mobx-persist)はMobXのデータを、よしなにLocalStorageに保存してくれるパッケージです。今回は簡単なTODOアプリの作成を通して、その使い方を説明します。

#### 想定読者
以下のいずれか

* TypescriptとReactとMobXでなんかごちゃごちゃやっているプログラムが読める
* これを読んだ人：[なる早でTypescript + Next + MobX + mobx-react-lite で非同期処理をサクッと扱う](なる早でTypescript + Next + MobX + mobx-react-lite で非同期処理をサクッと扱う)
* 
<details><summary> <font color="#b01">筆者の環境</font> </summary><div>
* **OS:** macOS Mojave 10.14.5
* **ブラウザ:** Safari バージョン12.1.1 
* **[Node.js](https://nodejs.org/ja/):** v10.16.0
* **[Yarn]():** 1.15.2
</div></details>

#### 今日作るもの
LocalStorageにデータを保持するから、リロードしてもTODOが消えないアプリ。


![画面収録 2019-07-03 18.36.15.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/403150/b65bcbb6-c85e-61bc-50e4-6b2b018f3e62.gif)

**できたやつ:** https://github.com/NanimonoDemonai/MobxPersist

# プロジェクトの準備
#### パッケージインストール

```
yarn add react react-dom next@canary mobx mobx-react-lite mobx-persist uuid localforage
yarn add -D typescript @types/react @types/react-dom @types/node babel-preset-mobx @types/uuid
```
<font color="#bbb">*/*TODO: Next.js 8.1.1がリリースされたら＠canaryを消す***/*</font>

<details><summary> <font color="#b01">執筆当時のpackage.json</font> </summary><div>

```json:package.json
{
  "devDependencies": {
    "@types/node": "^12.0.10",
    "@types/react": "^16.8.22",
    "@types/react-dom": "^16.8.4",
    "@types/uuid": "^3.4.5",
    "babel-preset-mobx": "^2.0.0",
    "prettier": "1.18.2",
    "typescript": "^3.5.2"
  },
  "dependencies": {
    "localforage": "^1.7.3",
    "mobx": "^5.10.1",
    "mobx-persist": "^0.4.1",
    "mobx-react-lite": "^1.4.1",
    "next": "^8.1.1-canary.64",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "uuid": "^3.3.2"
  }
}
```

</div></details>

#### 各種configの作成
以下のファイルを```package.json```と同階層に作成してください。

**.babelrc**

あとあと、クラスデコレータを使うので".babelrc"を作成して、編集しておく、`preset:"next/babel"`は`next@canary`に付いてくる。

```json:.babelrc
{
  "presets": [
    "next/babel",
    "mobx"
  ]
}
```

**indexページの作成**

`pages/index.tsx`を作成する。

```tsx:pages/index.tsx
export default () => (
   <h1>It Works!</h1>
);
```

**tsconfig.json**
```  "compilerOptions": {}```のメンバに```"experimentalDecorators": true,```があれば良い。

Next.jsの[GitHub](https://github.com/zeit/next.js#typescript)に書かれている`tsconfig.json`にこのオプションを挿した例はこれ。

```js:tsconfig.json
{
  "compilerOptions": {
    "allowJs": true, /* Allow JavaScript files to be type checked. */
    "alwaysStrict": true, /* Parse in strict mode. */
    "esModuleInterop": true, /* matches compilation setting */
    "isolatedModules": true, /* to match webpack loader */
    "jsx": "preserve", /* Preserves jsx outside of Next.js. */
    "lib": ["dom", "es2017"], /* List of library files to be included in the type checking. */
    "module": "esnext", /* Specifies the type of module to type check. */
    "moduleResolution": "node", /* Determine how modules get resolved. */
    "noEmit": true, /* Do not emit outputs. Makes sure tsc only does type checking. */

    "experimentalDecorators": true, /* ここに挿した */

    /* Strict Type-Checking Options, optional, but recommended. */
    "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
    "noUnusedLocals": true, /* Report errors on unused locals. */
    "noUnusedParameters": true, /* Report errors on unused parameters. */
    "strict": true /* Enable all strict type-checking options. */,
    "target": "esnext" /* The type checking input. */
  }
}
```


#### 開発用サーバの起動

以下のコマンドを実行すると

```
yarn next
```

以下にサーバが立つ
http://localhost:3000

# 単純なデータを永続化する定義

```ts:store/Todo.ts
import { persist } from "mobx-persist";
import { action, computed, configure, observable } from "mobx";
import uuid from "uuid";

configure({ enforceActions: "observed" });

export class TodoStore {
  @persist @observable todo: string;
  @persist @observable finished: boolean;
  @persist private readonly _date: string;
  readonly id: string;

  constructor(todo: string) {
    this.todo = todo;
    this._date = new Date().toString();
    this.finished = false;
    this.id = uuid.v4();
  }

  @computed get date() {
    return new Date(this._date);
  }

  @action.bound switchTodo() {
    this.finished = !this.finished;
  }
}
```

`mobx-persist`では、`@persist`デコレータを付けた値が`localStorage`に永続化される。`string`や`boolean`といったプリミティブな型はこのような形で、簡単に永続化することができる。従って`Date`オブジェクトは文字列で保存して、`getter`で取り出すときに日付にするようにした。

`mobx-persist`は`localStorage`からデータを呼び出すとき、まず`constructor`を呼び出し`@persist`デコレータの値を書き換えるような振る舞いをするので、上の例では`id`はリロードの度に変わる。

# 複雑なデータを永続化する定義

```ts:store/TodoList.ts
import { persist } from "mobx-persist";
import {
  action,
  computed,
  configure,
  IObservableArray,
  observable
} from "mobx";
import { TodoStore } from "./Todo";

configure({ enforceActions: "observed" });

export class TodoListStore {
  @persist(`list`, TodoStore)
  @observable
  private readonly _todoList: IObservableArray<TodoStore>;

  constructor(todoList: TodoStore[] = []) {
    this._todoList = todoList as IObservableArray<TodoStore>;
    this.addTodo(new TodoStore("初めてのTODO"));
  }

  @computed
  get todoList(): ReadonlyArray<TodoStore> {
    return this._todoList;
  }

  @action.bound
  addTodo(todo: TodoStore) {
    this._todoList.push(todo);
  }

  @action.bound
  removeTodo(children: TodoStore) {
    this._todoList.remove(children);
  }
}
```

配列や辞書、オブジェクトを`localStorage`に永続化する際には、  `@persist('list', TodoStore)`の形式でデコレートする必要がある。デコレータの第一引数には`'object' | 'list' | 'map'`いずれかの文字列を与え、第二引数にはクラスを与える。

詳細は[mobx-persist](https://github.com/pinqy520/mobx-persist)のUsageを参照されたし。

# `@persist`とlocalStorageを接続する
```ts:store/TodoListHydrator.ts
import { computed, configure, flow, observable } from "mobx";
import * as Persist from "mobx-persist";
import { TodoListStore } from "./TodoList";
import localForage from "localforage";

configure({ enforceActions: "observed" });

export enum TodoListHydratorStatus {
  init = "init",
  loading = "loading",
  loaded = "loaded"
}

export class TodoListHydrator {
  @observable private _todoList: TodoListStore | null = null;
  @observable private _saveName: string | null = null;
  @observable private _status: TodoListHydratorStatus =
    TodoListHydratorStatus.init;
  private _hydrator: ReturnType<typeof Persist.create> | null = null;

  @computed
  get todoList(): TodoListStore | null {
    return this._todoList;
  }

  @computed
  get saveName(): string | null {
    return this._saveName;
  }

  @computed
  get status(): TodoListHydratorStatus {
    return this._status;
  }

  private get hydrator(): ReturnType<typeof Persist.create> {
    if (this._hydrator == null) {
      localForage.config({
        driver: localForage.LOCALSTORAGE,
        name: "TodoApp"
      });

      this._hydrator = Persist.create({
        storage: localForage
      });

      return this._hydrator;
    }
    return this._hydrator;
  }

  readonly hydrate = flow(this.hydrateGenerator);

  private *hydrateGenerator(saveName: string) {
    this._saveName = saveName;
    this._todoList = null;
    this._status = TodoListHydratorStatus.loading;

    const newTodoList = new TodoListStore();

    yield this.hydrator<TodoListStore>(saveName, newTodoList);

    this._todoList = newTodoList;
    this._status = TodoListHydratorStatus.loaded;
  }
}
```

今回は`mobx`で非同期処理を書くために`flow`を使った。`@persist`デコレータを付けたメンバーと`localStorage`を接続する`hydrate`関数は、`Persist.create`で得られる。なお、`Persist.create`には`option`オブジェクトの`storage`メンバには`localStorage`の他に[localForage](https://github.com/localForage/localForage)のインスタンスが使える。`hydrate`関数は`Promise`を返すので、`await`なり`then`で非同期処理らしいものを書くのだが、`MobX`の`flow`を使うのならば、`await`の代わりに`yield`を書いてしまえば良い。

`hydrate`関数は第一引数に任意の名前の**key**を指定し、第二引数に接続する`object`を指定する。

# hydrateするタイミング

```tsx:pages/index.tsx
import { TodoListHydrator } from "../store/TodoListHydrator";
import { useEffect } from "react";
import { Observer } from "mobx-react-lite";
import { TodoListAdder } from "../components/TodoListAdder";
import { TodoListContext } from "../context";
import { TodoListShifter } from "../components/TodoListShifter";
import { TodoList } from "../components/TodoList";

const store = new TodoListHydrator();

export default () => {
  useEffect(() => {
    store.hydrate("Todo");
  }, []);
  return (
    <>
      <h1>TODOリスト</h1>
      <Observer>
        {() => (
          <TodoListContext.Provider value={store.todoList}>
            <p>
              {store.saveName}:{store.status}
            </p>
            <TodoListShifter hydrator={store} />

            <hr />

            <TodoList />
            <TodoListAdder />
          </TodoListContext.Provider>
        )}
      </Observer>
    </>
  );
};
```

`localStorage`はローカル側にのみ存在するので、先ほど作った`hydrete`は`useEffect`フックを使い呼び出すと良い。

# おまけ
**各々のコンポーネントの実装：**型の付け方の参考になるかも。

```Ts:context/index.js
import { createContext } from "react";
import { TodoListStore } from "../store/TodoList";

export const TodoListContext = createContext<TodoListStore | null>(null);
```


```tsx:components/Todo.tsx
import { FC } from "react";
import { Remover } from "./Remover";
import { TodoStore } from "../store/Todo";
import { Observer } from "mobx-react-lite";

export const Todo: FC<{ todo: TodoStore }> = props => (
  <>
    <li>
      <span onClick={props.todo.switchTodo}>
        <Observer>
          {() => (
            <span className={`${props.todo.finished && "finished"}`}>
              {props.todo.todo}：<DateView date={props.todo.date} />
            </span>
          )}
        </Observer>
      </span>
      <span className={"serial"}>{props.todo.id}</span>
      <Remover todo={props.todo} />
    </li>

    {/* language=CSS*/}
    <style jsx>{`
      .finished {
        text-decoration: line-through;
      }

      .serial {
        margin-left: 1em;
        font-size: 0.5em;
        color: #bbb;
      }
    `}</style>
  </>
);

export const DateView: FC<{ date: Date }> = props => (
  <>
    <span className={"time"}>
      <span>{props.date.getFullYear()}年</span>

      <span>{props.date.getMonth()}月</span>

      <span>{props.date.getDay()}日</span>

      <span>{props.date.getHours()}時</span>

      <span>{props.date.getMinutes()}分</span>

      <span>{props.date.getSeconds()}秒</span>
    </span>
    {/* language=CSS*/}
    <style jsx>{`
      .time {
        color: #bbb;
      }
    `}</style>
  </>
);
```

```tsx:components/TodoList.tsx
import { FC, useContext } from "react";
import { TodoListContext } from "../context";
import { Observer } from "mobx-react-lite";
import { Todo } from "./Todo";

export const TodoList: FC = () => {
  const todoList = useContext(TodoListContext);
  return (
    <>
      {todoList && (
        <Observer>
          {() => (
            <>
              {todoList.todoList.map(e => (
                <Todo key={e.id} todo={e} />
              ))}
            </>
          )}
        </Observer>
      )}
    </>
  );
};
```

```tsx:components/Remover.tsx
import { FC, useContext } from "react";
import { TodoListContext } from "../context";
import { TodoStore } from "../store/Todo";

export const Remover: FC<{ todo: TodoStore }> = props => {
  const todoList = useContext(TodoListContext);
  return (
    <>
      {todoList != null && (
        <button
          onClick={() => {
            todoList.removeTodo(props.todo);
          }}
        >
          削除
        </button>
      )}
    </>
  );
};
```

```tsx:components/TodoListAdder.tsx
import { FC, useContext, useState } from "react";
import { TodoListContext } from "../context";
import { TodoStore } from "../store/Todo";

export const TodoListAdder: FC = () => {
  const todoList = useContext(TodoListContext);
  const [text, setText] = useState<string>("");
  return (
    <>
      {todoList && (
        <>
          <input
            type="text"
            value={text}
            onChange={event1 => {
              setText(event1.target.value);
            }}
          />
          <button
            onClick={() => {
              todoList.addTodo(new TodoStore(text));
              setText("");
            }}
          >
            たす
          </button>
        </>
      )}
    </>
  );
};
```

```tsx:components/TodoListShifter.tsx
import { FC, useState } from "react";
import {
  TodoListHydrator,
  TodoListHydratorStatus
} from "../store/TodoListHydrator";
import { Observer } from "mobx-react-lite";

export const TodoListShifter: FC<{ hydrator: TodoListHydrator }> = props => {
  const [text, setText] = useState<string>("");
  return (
    <>
      <input
        type="text"
        value={text}
        onChange={event1 => {
          setText(event1.target.value);
        }}
      />
      <Observer>
        {() => (
          <button
            onClick={() => {
              setText("");
              props.hydrator.hydrate(text);
            }}
            disabled={props.hydrator.status == TodoListHydratorStatus.loading}
          >
            shift
          </button>
        )}
      </Observer>
    </>
  );
};
```