18
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GoodpatchAdvent Calendar 2019

Day 12

ReactでReduxを使わずにFluxパターンを実装する

Last updated at Posted at 2019-12-19

これはなに

新しめのReact (※) にはContextとHooksという2つのAPIが追加されており、これらを用いるとReduxで実装するようなFluxパターンを実現することが可能です。いつかくる最終戦争によって世界中のReduxが滅んでも、Reactさえ使えればクリーンな状態管理を続けることができるという訳ですね。

Reduxのサンプルでよくある絞り込み機能付きのToDoリストを用いて、storeを作ってblobal stateを共有しcomponentから利用するところまでを解説します。コードはGitHubにあります。

また、以下の本文中に添付するコードは可読性の都合で一部改変しているところがあります。ご了承ください。

※ - Context APIは v16.3.0、Hooks APIはv16.8.0 からです。

構成

src ディレクトリの中身はこんな感じです。

.
├── App.css
├── App.tsx
├── components
│   ├── TodoAdd
│   │   └── index.tsx
│   ├── TodoList
│   │   └── index.tsx
│   └── VisibilityFilterSelect
│       └── index.tsx
├── index.scss
├── index.tsx
├── react-app-env.d.ts
├── store
│   ├── index.tsx
│   ├── todos
│   │   ├── actions.ts
│   │   ├── index.tsx
│   │   ├── reducer.ts
│   │   └── types.ts
│   ├── visibilityFilter
│   │   ├── actions.ts
│   │   ├── index.tsx
│   │   ├── reducer.ts
│   │   └── types.ts
│   └── visibleTodos
│       └── index.tsx
└── types
    └── index.ts

ぱっと見た感じはReact-Reduxプロジェクトみのある構成ですね。

storeを作る

型を定義する

TypeScriptを使う場合のみですが、storeの型を定義しておきます。
todos の型はこんな感じです。

GitHub

src/store/todos/types.ts
export type TodosState = {
  id: number;
  text: string;
  completed: boolean;
}[];

export type TodosAction = {
  type: "ADD_TODO" | "TOGGLE_TODO";
  id?: number;
  text?: string;
};

export type TodosContextType = React.Context<{
  state: TodosState;
  dispatch: React.Dispatch<TodosAction>;
}>;

TodosStateTodosAction はReduxでも使うので「あーアレね」という感じだと思います。 TodosContextType はReduxでいう provider の中身に相当するもので、一旦 state をそれを変更するための dispatch が入る…ということだけ定義しておきます。

reducerを定義する

stateとactionの型が決まったので、Reducerを作っていきます。

GitHub

src/store/todos/reducer.ts
export default (
  state: TodosState,
  action: TodosAction
): TodosState => {
  switch (action.type) {
    case "ADD_TODO":
      return (action.text)
        ? [...state, {
          id: state.length,
          text: action.text,
          completed: false,
        }]
        : state;
    case "TOGGLE_TODO":
      return state.map(
        (item) => (item.id === action.id)
          ? { ...item, completed: !item.completed }
          : item
      );
    default:
      return state;
  }
};

まあ特に変なところはないですね。Reduxで使うReducerとなんら変わらないと思います。

actionsを定義する

Reducerに変更を通達するactionsを実装します。

GitHub

src/store/todos/actions.ts
export const addTodo = (text: string): TodosAction => ({
  type: "ADD_TODO",
  text,
})

export const toggleTodo = (id: number): TodosAction => ({
  type: "TOGGLE_TODO",
  id,
});

普通ですね。

ContextProviderを定義する

やってきました。本丸です。
Storeを作成し、その状態と変更関数を子componentに配布していきます。

GitHub

/store/todos/index.tsx
const initialState: TodosState = [];

export const TodosContext: TodosContextType = React.createContext<{
  state: TodosState;
  dispatch: React.Dispatch<TodosAction> | Noop;
}>({
  state: initialState,
  dispatch: () => {},
});

const TodosContextProvider: React.FC<React.Props<{}>> = ({
  children,
}): JSX.Element => {
  const [state, dispatch] = React.useReducer(reducer, initialState);
  return (
    <TodosContext.Provider value={{ state, dispatch }}>
      {children}
    </TodosContext.Provider>
  );
};

export default TodosContextProvider;

なげえ。一気にややこしくなりました。
順番に解説します。

まずは Context API を使って子componentに渡すための Context を実装しています。
ちなみに引数に渡す値は初期値ではなく 子componentが読み込みを試みたとき該当するProviderが無かった際に使われる値 です。実質ほぼ使われないのですが、適当にnullとか入れると型解析の都合が悪くなることなどもあり、いちおう初期値いれとくか…というぐらいの気持ちに落ち着きました。

子componentから呼び出す際にも使用するので export を忘れないように気をつけます。

/store/todos/index.tsx
const initialState: TodosState = [];

export const TodosContext: TodosContextType = React.createContext<{
  state: TodosState;
  dispatch: React.Dispatch<TodosAction> | Noop;
}>({
  state: initialState,
  dispatch: () => {},
});

で、いま作った Context に渡すReducerを作ります。

useReducer はreducerと初期値を受け取って statedispatch を返します。Hooksを利用したことでこれらはこのcomponentのstateとして管理されるため、Context APIをよく考えずに使うと起きる「状態更新の度に再描画が走る」リスクは心配無用だと思います。

また、Context Providerは1つのstoreにつき1つ定義することになるのでどうやってもネスト地獄が発生します。変に結合させるとややこしくなるので children を受け取る形で実装しておき、後でまとめるとよいかと思います。

/store/todos/index.tsx
const TodosContextProvider: React.FC<React.Props<{}>> = ({
  children,
}): JSX.Element => {
  const [state, dispatch] = React.useReducer(reducer, initialState);
  return (
    <TodosContext.Provider value={{ state, dispatch }}>
      {children}
    </TodosContext.Provider>
  );
};

export default TodosContextProvider;

storeを組み込む

いよいよアプリケーションにstoreを組み込んでゆきます。

GitHub

src/store/index.tsx
const ContextProvider: React.FC<React.Props<{}>> = ({
  children,
}): JSX.Element => (
  <VisibilityFilterContextProvider>
    <TodosContextProvider>
      <VisibleTodosContextProvider>
        {children}
      </VisibleTodosContextProvider>
    </TodosContextProvider>
  </VisibilityFilterContextProvider>
);

export default ContextProvider;

ネスト地獄です。こればかりは避けられませんが、逆にこういうAPI都合の謎構造は一箇所に固めて (ある程度) ブラックボックス化させておけるので、割り切ることもできるような気がしています。

特に言うことはないですが、後で解説するように「複数storeのstateを集約する」みたいなことをやろうとすると、ネストの順番が割と大事になってくるので注意が必要です。

最後にこれらをアプリケーションに接続します。 App.tsx 的な、ルート階層に近い部分で行います。このサンプルだと src 直下ですね。

GitHub

src/index.tsx
const App: React.FC = (): JSX.Element => (
  <ContextProvider>
    <div className="container">
      <TodoAdd />
      <VisibilityFilterSelect />
      <TodoList />
    </div>
  </ContextProvider>
);

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

これでアプリケーションとstoreがつながりました。
いよいよ状態の読み出しと更新を行なっていきます。

storeを使う

ToDoリストへの項目追加を行う TodoAdd コンポーネントを例に解説します。

GitHub

src/components/TodoAdd/index.tsx
const TodoAdd: React.FC = (): JSX.Element => {
  const { state, dispatch } = React.useContext(TodosContext);
  const [ text, setText ] = React.useState("");
  const handleClick = () => {
    if (!dispatch) return;
    dispatch(addTodo(text));
    setText("");
  }
  return (
    <form className="TodoAdd">
      <input
        type="text"
        value={text}
        onChange={e => setText(e.currentTarget.value)}
        className="TodoAdd__input"
      />
      <button onClick={handleClick} disabled={text === ""} className="TodoAdd__button">追加</button>
    </form>
  )
}

export default TodoAdd;

大事なのは useContext を利用してContextから値を取り出し、そのまま利用できるという点ですね。

src/components/TodoAdd/index.tsx
// ↓ここでContextを読み込んで
import { TodosContext } from "../../store/todos";

const TodoAdd: React.FC = (): JSX.Element => {
  // ↓ ここで値を取り出している!
  const { state, dispatch } = React.useContext(TodosContext);
  const [ text, setText ] = React.useState("");
  const handleClick = () => {
    if (!dispatch) return;
    // ↓ アクションをdispatchして状態を更新できる!
    dispatch(addTodo(text));
    setText("");
  }
  // ...

いいですね〜Reduxの connect に比べてシンプルな印象がないでしょうか。そんなことないですか。

おまけ

combinated storeを作る

2つ以上のstoreを結合したいときには単純にcontextをたくさん読み込んで計算した結果を新たなcontextとして配布すればよいのではないかと思います。

以下は visibilityFilter (表示するToDo項目の絞り込み) と todos (ToDo全項目) を使って「表示するToDo項目」を作っているものです。

GitHub

src/store/visibleTodos/index.tsx
export const visibleTodosContext = React.createContext<{
  state: TodosState;
}>({
  state: initialState,
});

const VisibleTodosContextProvider: React.FC<React.Props<{}>> = ({
  children,
}): JSX.Element => {
  const { state: todosState } = React.useContext(TodosContext);
  const { state: visibilityFilterState } = React.useContext(VisibilityFilterContext);
  const stateFactory = (todos: TodosState): TodosState => {
    switch(visibilityFilterState) {
      case "SHOW_ALL":
        return todos;
      case "SHOW_ACTIVE":
        return todos.filter(todo => !todo.completed)
      case "SHOW_COMPLETED":
        return todos.filter(todo => todo.completed)
      default:
        return todos;
    }
  }
  
  return (
    <visibleTodosContext.Provider value={{ state: stateFactory(todosState) }}>
      {children}
    </visibleTodosContext.Provider>
  )
}

export default VisibleTodosContextProvider;

前述しましたが、 Context APIのProviderは子componentにしか状態を伝播させない ので、これを ContextProvider のルート階層に持っていくとエラーになります。
ちょっと気持ち悪さがありますが、やむを得ない感じもあります。

課題など

今回は使いませんでしたが、ほとんどのアプリケーションではAPIへのアクセスなど非同期処理が発生すると思います。今の所Reducer Hookにはmiddlewareの利用にデファクト的なものがないっぽいので、どうハンドリングするかは少し考える必要があります。同期的に処理の開始と完了で2つactionを発行するという手もあるし、自前でmiddleware的なものを実装する という手もあるでしょう。

まとめ

これでRedux封じに遭っても安心ですね。
今はまだ「使えんこともない」という感じですが、こうしたReact本来のAPIを活かしつつ拡張していくようなエコシステムが成熟してくると大分可能性はあるな…と感じます。以上です。

18
8
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
18
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?