JavaScript
TypeScript
React
frontend

【Reduxに疲れた人のための】Undux入門

巷のフロントエンドではReact+Reduxの技術スタックがどちゃクソ流行ってますね!

とはいえ、Reduxだとやはりどうしてもひとつアプリを作る際のボイラプレートコードの多さや、ステートの更新によってトリガされる副作用処理のハンドリングをredux-sagaredux-side-effectsなしではうまくやれないケースが多いです。

もし、Reduxとその周辺ライブラリのよせあつめにちょっとでもツラミを感じ始めたら、Unduxを試すチャンスです。

Unduxとは?

Dead simple state management for React

Reactのための死ぬほどシンプルなステート管理ライブラリです。

特徴

  • TypeScriptやFlowなどでの型付けを意識した設計。
  • ReduxにおけるAction, Reducer, Dispatcher, Containerなどの概念を必要としない。
  • getsetという抽象化されたステート操作のみ。

の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つのイベントハンドラをサポートしています。beforebeforeAllはストアに対するsetによって変更が行われる直前で走るハンドラです。

定義されたハンドラはストアを食べる形で適用します。

import { createStore, withLocalStorage } from 'undux'

const store = withLocalStorage(createStore<Store>({...}))

TODOアプリ実装例

さて、Undux+Reactを用いて簡単なTODOアプリを作ってみました。
実際に動くものは以下のCodeSandboxからどうぞ。
https://codesandbox.io/s/kw1vvv5mp5

スクリーンショット 2018-02-15 15.24.45.png

Model

モデルはImmutable#Recordを使ったTodoモデルのみです

todo.ts
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の登録を行っています。

store.ts
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レイヤはそれらの操作を隠蔽する責務として、ストアを食べてアクションを返すという純粋関数として位置づけます。

todoAction.ts
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

todoEffect.ts
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のみをサブスクライブします。

newTodo.tsx
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コンポーネントは、todostodoCountの変更をサブスクライブしています。

todoList.tsx
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と比べるとアプリケーションのレイヤリングをしっかりやっていかないとすぐにコンポーネントとストアとの関係が密結合になってしまう危険性もあるような気がするので、ここは注意が必要かもしれません。開発をするチームのメンバーで具体的な設計方針をあらかじめ考えておくと、スピードとメンテナビリティを共存させた、うまいアプリケーション開発ができるようになるはずです :muscle:


  1. ReduxのActionとは全くの別物です 

  2. そもそも todoCount などというステートを持たせなくても、Immutable.jsのsizeプロパティで取れますが、ここではEffectを使うために敢えてこのステートを持たせています。