概要
mobx-persistはMobXのデータを、よしなにLocalStorageに保存してくれるパッケージです。今回は簡単なTODOアプリの作成を通して、その使い方を説明します。
想定読者
以下のいずれか
- TypescriptとReactとMobXでなんかごちゃごちゃやっているプログラムが読める
- これを読んだ人:なる早でTypescript + Next + MobX + mobx-react-lite で非同期処理をサクッと扱う
今日作るもの
LocalStorageにデータを保持するから、リロードしてもTODOが消えないアプリ。
できたやつ: 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
{
"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
に付いてくる。
{
"presets": [
"next/babel",
"mobx"
]
}
indexページの作成
pages/index.tsx
を作成する。
export default () => (
<h1>It Works!</h1>
);
tsconfig.json
"compilerOptions": {}
のメンバに"experimentalDecorators": true,
があれば良い。
Next.jsのGitHubに書かれている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
単純なデータを永続化する定義
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
はリロードの度に変わる。
複雑なデータを永続化する定義
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を接続する
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
で非同期処理らしいものを書くのだが、MobX
のflow
を使うのならば、await
の代わりにyield
を書いてしまえば良い。
hydrate
関数は第一引数に任意の名前のkeyを指定し、第二引数に接続するobject
を指定する。
hydrateするタイミング
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
フックを使い呼び出すと良い。
おまけ
各々のコンポーネントの実装:型の付け方の参考になるかも。
import { createContext } from "react";
import { TodoListStore } from "../store/TodoList";
export const TodoListContext = createContext<TodoListStore | null>(null);
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>
</>
);
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>
)}
</>
);
};
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>
)}
</>
);
};
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>
</>
)}
</>
);
};
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>
</>
);
};