巷のフロントエンドではReact+Reduxの技術スタックがどちゃクソ流行ってますね!
とはいえ、Reduxだとやはりどうしてもひとつアプリを作る際のボイラプレートコードの多さや、ステートの更新によってトリガされる副作用処理のハンドリングをredux-sagaやredux-side-effectsなしではうまくやれないケースが多いです。
もし、Reduxとその周辺ライブラリのよせあつめにちょっとでもツラミを感じ始めたら、Unduxを試すチャンスです。
Unduxとは?
Dead simple state management for React
Reactのための死ぬほどシンプルなステート管理ライブラリです。
特徴
- TypeScriptやFlowなどでの型付けを意識した設計。
- Reduxにおける
Action
,Reducer
,Dispatcher
,Container
などの概念を必要としない。 -
get
とset
という抽象化されたステート操作のみ。
の3つが大きなUnduxの特徴です。
使ってみると分かるのは、どちらかというとReduxよりもMobxに近いです。
学習コスト的には Redux > Mobx > Undux という感じではないかと感じます。
使ってみる
UnduxにはStoreの概念しかなく、定義したそのStoreをコンポーネントへ接続し、コンポーネントからストアに対する操作を行うというのが主な使い方になります
Store定義
import { connect, createStore } from 'undux'
// Storeのインターフェースを定義しておく
interface Store {
today: Date
users: string[]
}
// Storeの初期値をセット
const store = createStore<Store>({
today: new Date,
users: []
})
// 接続先ストアを引数にとるHOCを作成
export const withStore = connect(store)
connect
関数は命名こそreact-reduxで提供されているものと同じですが、使い方はMobxで言うところの@inject
に近いです。
Component定義
上で定義したwithStore
を以下のように使います。
import { withStore } from './store'
// Update the component when `today` changes
let MyComponent = withStore('today')(({ store }) =>
<div>
Hello! Today is {store.get('today')}
<button onClick={() => store.set('today')(new Date)}>Update Date</button>
</div>
)
withStore
は引数に与えられたキー名を元にStoreサブスクライブするHOCを返しています。ここではtoday
のみを対象にしていますが、複数のキーを指定することも可能です。
const MyComponent = withStore('today', 'users')(({ store }) =>
...
)
これはLensed connect
と呼ばれる機能で、サブスクライブ対象のキー以外の更新はコンポーネントの描画をトリガしません。つまり、ひとつめのコンポーネント定義のようにtoday
のみを引数に与えている場合は、users
の変更によってコンポーネントが描画されないということです。
これだけでUnduxは使えます!
(もちろんステートフルコンポーネントと一緒にも使えます)
その他の機能
Unduxは上で説明されたような基本的な機能に加えて
- 副作用処理をハンドリングするためのEffect
- ミドルウェアに相当するPlugin
のふたつが最小限備わっています。
Effect
UnduxのEffectは、MobxのReactionという機能に近いもので、**ステートの変更によってトリガされるアクション(副作用)**を定義できる機能です。
フロントエンド・アプリケーションを作っていると必ずと行っていいほど遭遇するのが、非同期処理です。UnduxのEffectを使うと、RxJSの記法を用いて効率的に非同期処理を扱うコードを書くことができます。
たとえば、以下はuserFetchingStatus
の状態をサブスクライブし、外部のAPIへのデータの取得を行うためのEffectの実装です。
store
.on('userFetchingStatus')
.filter(s => s === FETCHING_STATUS.STARTED)
.subscribe(() => {
UserRepository.getAll().then((users: I.List<User>) => {
succeededFetchingUsers(users);
}).catch(() => {
failedFetchingUsers();
});
});
Effectの存在によって、アクションの基点がかならずしもビューを経由したユーザーからの操作だけにとどまらなくなります。
このような副作用ハンドリングがないと、ステートのマッピング先になるビューで条件分岐を記述しなければいけないため、ロジックがビューに散らばってしまいます。
(ReduxにはこのEffectに相当する機能がないため、redux-sagaなどを補助として使う必要があります)
Plugin
Unduxのプラグインは、HOCの形で定義します。
以下はモデルの状態をLocalStorageへシームレスに永続化するプラグインの例です。
import { createStore, Plugin } from 'undux'
let withLocalStorage: Plugin = store => {
// モデルの変更を検知して、新しい変更をローカルストレージへ保存
store.beforeAll().subscribe(({ key, previousValue, value }) =>
localStorage.set(key, value)
)
return store;
}
Unduxのストアは、beforeAll
以外にもonAll
, before
, on
の4つのイベントハンドラをサポートしています。before
やbeforeAll
はストアに対するset
によって変更が行われる直前で走るハンドラです。
定義されたハンドラはストアを食べる形で適用します。
import { createStore, withLocalStorage } from 'undux'
const store = withLocalStorage(createStore<Store>({...}))
TODOアプリ実装例
さて、Undux+Reactを用いて簡単なTODOアプリを作ってみました。
実際に動くものは以下のCodeSandboxからどうぞ。
https://codesandbox.io/s/kw1vvv5mp5
Model
モデルはImmutable#Record
を使ったTodo
モデルのみです
import * as I from "immutable";
export default class Todo extends I.Record({
name: "",
isDone: false
}) {
static setDone(todo: Todo) {
return todo.set("isDone", true);
}
}
Store
ここでは、createStore
関数を用いて初期状態を指定したストアの定義と、Effectの登録を行っています。
import * as I from "immutable";
import { connect, createStore } from "undux";
import { registerTodoEffect } from "./effects/todoEffect";
import Todo from "./models/todo";
export interface AppStore {
newTodoName: string;
todoCount: number;
todos: I.List<Todo>;
}
const store = createStore<AppStore>({
newTodoName: "",
todoCount: 0,
todos: I.List([])
});
registerTodoEffect(store);
export const withStore = connect(store);
今回はストアに格納されるデータの種類が少ないため、ストアの分割をしません。
Action
このTodoアプリでは、Actionレイヤ1を導入しています。
Unduxはストアに対する操作が抽象化されているため、コードがある程度大きくなってくると、どうしてもコンポーネントにストアに対する操作のロジックが散らばってしまいます。Actionレイヤはそれらの操作を隠蔽する責務として、ストアを食べてアクションを返すという純粋関数として位置づけます。
import { Store } from "undux";
import { AppStore } from "../store";
import Todo from "../models/todo";
export const todoActions = (store: Store<AppStore>) => {
return {
updateNewTodoName(name: string) {
store.set("newTodoName")(name);
},
addNewTodo() {
const name = store.get("newTodoName");
const currentTodos = store.get("todos");
const todo = new Todo({ name });
const updatedTodos = currentTodos.push(todo);
store.set("todos")(updatedTodos);
store.set("newTodoName")("");
},
markTodoDone(index: number) {
const currentTodos = store.get("todos");
const nextTodos = currentTodos.update(index, (todo: Todo) =>
Todo.setDone(todo)
);
store.set("todos")(nextTodos);
}
};
};
コンポーネント側は、ストアのデータ構造を詳しくしらなくても、アクションによって提供される関数を呼ぶだけで、ストアに対する具体的な操作ができるようになります。
Effect
ストアの変更の副作用として、todoCount
を更新しています2。
import * as I from "immutable";
import Todo from "../models/todo";
import { Store } from "undux";
import { AppStore } from "../store";
export function registerTodoEffect(store: Store<AppStore>) {
store.on("todos").subscribe((todos: I.List<Todo>) => {
store.set("todoCount")(todos.size);
});
}
Component
コンポーネントでは、さきほどstore.tsで定義されたwithStore
関数を使って、ストアとの接続をします。
newTodo
コンポーネントはフォームの状態のみにしか関心がないため、ストアからnewTodoName
のみをサブスクライブします。
import * as React from "react";
import { withStore } from "../store";
import { todoActions } from "../actions/todoActions";
export const NewTodo = withStore("newTodoName")(({ store }) => {
const { addNewTodo, updateNewTodoName } = todoActions(store);
const todoName = store.get("newTodoName");
return (
<div style={{ marginBottom: 5 }}>
<input
type="text"
value={todoName}
placeholder="Put your todo here..."
onChange={e => {
updateNewTodoName(e.target.value);
}}
style={{ marginRight: 5 }}
/>
<button
disabled={todoName === ""}
onClick={() => {
addNewTodo();
}}
>
Add
</button>
</div>
);
});
一方で、todoList
コンポーネントは、todos
とtodoCount
の変更をサブスクライブしています。
import * as React from "react";
import { withStore } from "../store";
import { todoActions } from "../actions/todoActions";
import Todo from "../models/todo";
export const TodoList = withStore("todos", "todoCount")(({ store }) => {
const { markTodoDone } = todoActions(store);
const $todos = store
.get("todos")
.toArray()
.map((todo: Todo, index: number) => (
<div
className="todolist-item"
key={index}
onClick={() => {
markTodoDone(index);
}}
style={{
textDecoration: todo.get("isDone") ? "line-through" : "none"
}}
>
{todo.get("name")}
</div>
));
const todoCount = store.get("todoCount");
return (
<div>
<div>Todo count: {todoCount}</div>
<ul className="todolist">{$todos}</ul>
</div>
);
});
コンポーネントでは、ストアからのデータの取り出しにget
を何度も使っていますが、もっと大規模なアプリケーションになってくると、コンポーネントで必要になるデータ構造と、ストアのデータ構造を切り離して疎結合にしたいケースがでてきます。その場合には、今回の例で導入したアクションのように、ストアを食ってコンポーネントに固有なデータ構造を返すようなレイヤを導入してみてもいいかもしれません。
所感
個人的にUnduxがとても良いなと思う理由は
- ボイラプレートコードが不要なので記述量が少ない。
- Undux自体のソースコードがめちゃめちゃ小さいのですぐ読める。
- 副作用のハンドリング方法がビルトインで用意されている。
の3点で、基本的にはすごく使い勝手の良いステート管理ライブラリなんではないかなと思います。僕自身、上で紹介したTodoアプリとほぼほぼ同じレイヤー区分で、すでにUnduxをReactNativeとの開発で使い始めています。
とはいえ、用意されているAPIがシンプルであるがゆえに、Reduxと比べるとアプリケーションのレイヤリングをしっかりやっていかないとすぐにコンポーネントとストアとの関係が密結合になってしまう危険性もあるような気がするので、ここは注意が必要かもしれません。開発をするチームのメンバーで具体的な設計方針をあらかじめ考えておくと、スピードとメンテナビリティを共存させた、うまいアプリケーション開発ができるようになるはずです