UZABASE Tech Blog

〜迷ったら挑戦する道を選ぶ〜 株式会社ユーザベースの技術チームブログです。

ReactとReactHooksを使って、Flux的なアーキテクチャを実現する

こんにちは。SPEEDA開発チームの冨田です。

昨今のフロントエンドでは、Fluxというアーキテクチャが利用されることが多くなってきています。SPEEDAでもVueを使っている画面がありますが、そこではVuexというVue向けのFluxライブラリで状態管理をしています。

Fluxではデータの流れを一方向にすることで見通しのよい設計が行えるようになります。

今回は、素のReactを使ってデータの流れを単一方向にする設計を紹介します。

今回作ってみるもの

Todoアプリを作ってみましょう。 以下のようなことができる画面を作ります。なお、今回はデータの永続化は考えないものとします(つまり、ウェブページを更新すると、全て消えてしまいます)。

  • Todoを追加することができる
  • 全てのTodoのリストを見ることができる
  • Todoの完了状態を変更することができる

セットアップ

簡単のため、CodeSandboxなどで、React + TypeScriptなプロジェクトを作っておくとよいでしょう。以下は、今回作成したプロジェクトになります。

codesandbox.io

1. GettersとActionsをつくる

Todoアプリを実現するためのカスタムフックを作りましょう。

Gettersはコンポーネントからアクセスできるようにする(露出する)データの集まりです。Actionsはそれを呼び出すことで、なんらかの副作用を起こし、Gettersを更新する関数の集まりです(正確には、内部のStateを変更することで、Gettersが更新される)。これらはFluxで定義されているものと同様の概念だと考えていただいてよいでしょう。

今回はGetters, Actionsとして以下のものを定義しています。

  • todos: Todoのリストを保持する配列
  • add: 文字列を受け取り、Todoのリストに新規のTodoを追加する
  • update: idと真偽値を受け取り、リストの中の該当するTodoの完了状態を変更する
import { useState } from "react";

interface Todo {
  description: string;
  done: boolean;
}

interface State {
  todos: Todo[];
}

interface Getters {
  todos: Array<Todo & { id: number }>;
}

interface Actions {
  add(description: string): void;
  update(id: number, done: boolean): void;
}

export type Store = Getters & Actions;

export function useTodo(): Getters & Actions {
  const [state, setState] = useState<State>({ todos: [] });
  const { todos } = state;

  function add(description: string) {
    setState({
      todos: [...todos, { description, done: false }]
    });
  }

  function update(id: number, done: boolean) {
    setState({
      todos: todos.map((it, i) => (i !== id ? it : { ...it, done }))
    });
  }

  return {
    todos: todos.map((it, id) => ({ ...it, id })),
    add,
    update
  };
}

2. GettersとActionsをどこのコンポーネントからでもアクセスできるようにする

useContextを使います。 useContextは、createContextにより、あらかじめ生成しておいたコンテキストにどこからでもアクセスできるようにするReactHooksです。

例:Appコンポーネント内で、Context.Providervalue propsに渡したオブジェクトがChildElementコンポーネント内のuseContextで得られます。

const Context = createContext({});

const ChildElement = () => {
  const { foo } = useContext(Context);
  return (
    <div>{foo}</div>
  );
}

const App = () => {
  return (
    <Context.Provider value={{ foo: "bar" }}>
      <ChildElement />
    </Context.Provider>
  );
};

上記の仕組みを利用して、GettersとActionsに対してどこからでもアクセスできるようにし、さらにGettersに変更が行われると、再レンダーされるようにします。

createContextでコンテキストを作成します。ここでは引数に{} as anyとやってしまっていますが、Providerでvalueを提供しないときに初期値として得られるものなので、これでよいでしょう。

StoreProviderコンポーネントは、Context.Providerをラップしたものです。コンテキストにuseTodoで生成したGettersとActionsを保存し、子コンポーネントをレンダリングします。 ユーザーに、Contextやカスタムフックを意識させないための工夫です。

useContextもReactのuseContextをラップしたものです。こちらも、Contextを意識させないために作っています。

ここで定義したStoreProviderとuseContextを利用することで、画面のコンポーネントではカスタムフックについて意識することなく、useContextを呼ぶだけでなんか知らんけどGettersとActionsが使える、という状態になります。

import React, { createContext, useContext as useContextOriginal, FC } from "react";
import { useTodo, Store } from "./Store";

interface AppStore {
  todo: Store;
}

const Context = createContext<AppStore>({} as any);
const { Provider } = Context;

export const StoreProvider: FC = ({ children }) => {
  const todo = useTodo();
  return <Provider value={{ todo }}>{children}</Provider>;
};

export function useContext() {
  return useContextOriginal(Context);
}

3. Todoを表示するコンポーネントを作る

ユーザーが入力しているdescriptionについてはこのコンポーネントで管理するようにしています。これも上で作ったGetters, Actionsで管理してもよいのですが、useStateとの共存も示したかったので、今回はここに書きました。

実装自体は、普通のulとinput, buttonを使ったコンポーネントですが、useContextを利用することで、todos, add, updateにアクセスできるようになりました。

追加ボタンが押され、addが呼び出されることで、todosが更新され、再レンダリングが行われます。

チェックボックスが変更されることで、updateが呼び出され、todosが更新され、再レンダリングが行われます。

Getters(todos)は、画面をレンダリングするために必要なもの。Actions(add, update)は呼び出すことで、状態を変更し、再レンダリングを促すもの。データの流れが単一方向になっているのがわかるでしょうか。

import React, { FC, useState } from "react";
import { useContext } from "./Flux";

export const TodoComponent: FC = () => {
  const [currentDescription, setCurrentDescription] = useState("");
  const { todo: { todos, add, update } } = useContext();

  function onAddClick() {
    setCurrentDescription("");
    add(currentDescription);
  }

  return (
    <>
      <ul>
        {todos.map(({ id, description, done }) => (
          <li key={id}>
            {description}
            <input
              type="checkbox"
              checked={done}
              onChange={e => update(id, e.target.checked)}
            />
          </li>
        ))}
      </ul>
      <input
        value={currentDescription}
        onChange={e => setCurrentDescription(e.target.value)}
      />
      <button onClick={onAddClick}>追加</button>
    </>
  );
};

4. アプリコンポーネントを作る

最後に、アプリコンポーネント(一番外側のコンポーネント)を作ります。

2で作ったStoreProviderがここで登場します。 3で作ったTodoComponentをStoreProviderで包むだけです。 これにより、内部で状態が変化したときに、再レンダリングが走ります。

import React from "react";
import { render } from "react-dom";
import { TodoComponent } from "./TodoComponent";
import { StoreProvider } from "./Flux";

const App = () => {
  return (
    <StoreProvider>
      <TodoComponent />
    </StoreProvider>
  );
};

render(<App />, document.getElementById("root"));

5. テストを書く

順番が前後して申し訳ないのですが、最後におまけとして、Getters, Actionsのテストの書き方をご紹介します。

ReactのカスタムフックはReactコンポーネント内でしか呼び出すことができません。 すなわち、テスト用のReactコンポーネント内でカスタムフックを呼び出し、呼び出した結果をテストすることになるでしょう。

react-domが提供するactという関数のコールバックでレンダリングやカスタムフックが生成したGetters, Actionsを取得することができます。

今回はbeforeEachで、テスト用のコンポーネントをマウントしさらにuseTodoが返すGetters, Actionsを変数に保持しています。Specification内では得られたGetterやActionsのテストのみに注力しています。

注意点としてはact内でActionsを呼び出せるのは1回で、何度も呼び出したい場合はactを何度も書く必要があることです。actを抜けると、状態がGetterに反映されるイメージです。

テストケースは3つです。 ひとつめはtodosの初期状態が空配列であること。 ふたつめはaddすると、todosに要素がひとつ追加されること。 みっつめは、addした要素に対しupdateをかけると、その要素のdoneの状態が、指定したとおりに変更されること。

import React from "react";
import { render } from "react-dom";
import { act } from "react-dom/test-utils";
import { useTodo, Store } from "../Store";

describe("#useTodo", () => {
  let container;
  describe("useTodo called.", () => {
    let state: Store;
    beforeEach(() => {
      container = document.createElement("div");
      const App = () => ((state = useTodo()), null);
      act(() => {
        render(<App />, container);
      });
    });
    afterEach(() => (container = null));

    it("should have action and state.", () => {
      expect(state.todos).toEqual([]);
    });

    it("should add todos when 'add' called.", () => {
      act(() => state.add("foo"));
      expect(state.todos).toEqual([{ description: "foo", done: false, id: 0 }]);
    });

    it("should update done status when 'update' called.", () => {
      act(() => state.add("foo"));
      act(() => state.add("bar"));
      act(() => state.update(0, true));
      expect(state.todos).toEqual([
        { description: "foo", done: true, id: 0 },
        { description: "bar", done: false, id: 1 }
      ]);
    });
  });
});

以上のように、素のReactだけでFlux的なアーキテクチャを実現することができました。 よろしければ、ぜひお試しください。