LoginSignup
2
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-07-03

概要

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

想定読者

以下のいずれか

今日作るもの

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

画面収録 2019-07-03 18.36.15.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

/*TODO: Next.js 8.1.1がリリースされたら@canaryを消す*/

執筆当時のpackage.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"
  }
}

各種configの作成

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

.babelrc

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

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

indexページの作成

pages/index.tsxを作成する。

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

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

Next.jsのGitHubに書かれているtsconfig.jsonにこのオプションを挿した例はこれ。

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

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

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に永続化される。stringbooleanといったプリミティブな型はこのような形で、簡単に永続化することができる。従ってDateオブジェクトは文字列で保存して、getterで取り出すときに日付にするようにした。

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

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

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のUsageを参照されたし。

@persistとlocalStorageを接続する

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のインスタンスが使える。hydrate関数はPromiseを返すので、awaitなりthenで非同期処理らしいものを書くのだが、MobXflowを使うのならば、awaitの代わりにyieldを書いてしまえば良い。

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

hydrateするタイミング

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はローカル側にのみ存在するので、先ほど作ったhydreteuseEffectフックを使い呼び出すと良い。

おまけ

各々のコンポーネントの実装:型の付け方の参考になるかも。

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

export const TodoListContext = createContext<TodoListStore | null>(null);
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>
  </>
);
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>
      )}
    </>
  );
};
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>
      )}
    </>
  );
};
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>
        </>
      )}
    </>
  );
};
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>
    </>
  );
};
2
0
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
2
0