🦁

React Context APIをState、Dispatch専用に分けて使う方法

2021/02/19に公開

ReactのContext APIを使う時、コードの構造をどうするかについては決まっている方法はありません。Conext APIを作成するためにクラスコンポーネントをつかっても関数コンポーネントをつかっても問題はありません。関数コンポーネントを使う時もuseReducerもuseStateも使えます。

今回はContext APIを使ってTodoListを作りながら、Context APIを活用する色々な方法の中、個人的に好きな方法を紹介したいと思います。

プロジェクト準備

以下のCodeSandboxからContext API適用前の簡単なTodoListを確認してください。ここからContext APIを作成していきたいと思います。各コンポーネントの役割はコンポーネント名の通りです。

Context作成

それでは、Context APIを作成します。まずsrc/contexts/TodosContext.tsxファイルを作ります。これからTodosContext.tsx2つのContextを作ってみます。1つはState専用、もう1つはDispatch専用のContextです。このように2つのContextを作ると、無駄なレンダリングを防ぐことができます。
StateとDispatchを1つのContextにまとめて入れると、TodoFormのようなStateは要らなくDispatchだけ必要なコンポーネントもStateが更新される時に再レンダリングされます。

src/contexts/TodosContext.tsx

import { createContext, Dispatch, ReactNode } from 'react';

export type Todo = {
  id: number;
  text: string;
  done: boolean;
};

type TodosState = Todo[];

// * State専用 Context *
// 今後 Providerを使わない時にはContextの値がundefinedになる必要があるので, 
// Contextの値がTodosStateにもundefinedにもできるように宣言してください。
const TodosStateContext = createContext<TodosState | undefined>(undefined);


type Action =
  | { type: 'CREATE'; text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'REMOVE'; id: number };

type TodosDispatch = Dispatch<Action>;

// * Dispatch専用 Context *
const TodosDispatchContext = createContext<TodosDispatch | undefined>(
  undefined
);


// * Reducer *
function todosReducer(state: TodosState, action: Action): TodosState {
  switch (action.type) {
    case 'CREATE':
      const nextId = Math.max(...state.map(todo => todo.id)) + 1;
      return state.concat({
        id: nextId,
        text: action.text,
        done: false
      });
    case 'TOGGLE':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, done: !todo.done } : todo
      );
    case 'REMOVE':
      return state.filter(todo => todo.id !== action.id);
    default:
      throw new Error('Invalid action');
  }
}


// TodosStateContextとTodosDispatchContextのProviderを一緒に使います。 
export function TodosContextProvider({ children }: { children: ReactNode }) {
  const [todos, dispatch] = useReducer(todosReducer, [
    {
      id: 1,
      text: 'Context APIを勉強する',
      done: true
    },
    {
      id: 2,
      text: 'TypeScriptを勉強する',
      done: true
    },
    {
      id: 3,
      text: 'TypeScriptでContext APIを使ってみる',
      done: false
    }
  ]);

  return (
    <TodosDispatchContext.Provider value={dispatch}>
      <TodosStateContext.Provider value={todos}>
        {children}
      </TodosStateContext.Provider>
    </TodosDispatchContext.Provider>
  );
}

カスタムHooks作成

TodosStateContextとTodosDispatchContextを使う時は以下のようにuseContextを使ってContextの値が使えるようになります。

const todos = useContext(TodosStateContext);

ここで、todosのtypeはTodoState | undefinedなので使う前に値をチェックする必要があります。

const todos = useContext(TodosStateContext);
if (!todos) return null;

このように値をチェックして使用しても問題はありませんが、より良い方法としてはTodosContext専用Hooksを作って使用する方法があります。

// src/contexts/TodosContext.tsx
import { createContext, Dispatch, useReducer, useContext } from 'react';

(...前のコード省略)
 
export function useTodosState() {
  const state = useContext(TodosStateContext);
  if (!state) throw new Error('TodosProvider not found');
  return state;
}

export function useTodosDispatch() {
  const dispatch = useContext(TodosDispatchContext);
  if (!dispatch) throw new Error('TodosProvider not found');
  return dispatch;
}

関数から必要な値がない時はエラーを出すので各Hookが返す値のtypeは有効である保障できます。

src/App.tsx

import TodoForm from './components/TodoForm';
import TodoList from './components/TodoList';
import { TodosContextProvider } from './contexts/TodosContext';

const App = () => {
  return (
    <TodosContextProvider>
      <TodoForm />
      <TodoList />
    </TodosContextProvider>
  );
};

export default App;

src/components/TodoList.tsx

import TodoItem from './TodoItem';
import { useTodosState } from '../contexts/TodosContext';

function TodoList() {
  const todos = useTodosState();
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem todo={todo} key={todo.id} />
      ))}
    </ul>
  );
}

export default TodoList;

src/components/TodoForm.tsx

import { useState, FormEvent } from 'react';
import { useTodosDispatch } from '../contexts/TodosContext';

function TodoForm() {
  const [value, setValue] = useState('');
  const dispatch = useTodosDispatch();

  const onSubmit = (e: FormEvent) => {
    e.preventDefault();
    dispatch({
      type: 'CREATE',
      text: value
    });
    setValue('');
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        value={value}
        placeholder="やることを入力してくださいー"
        onChange={e => setValue(e.target.value)}
      />
      <button>追加</button>
    </form>
  );
}

export default TodoForm;

src/components/TodoItem.tsx

一般的な場合、Contextを使わない環境ではTodoItemコンポーネントからonToggleonRemoveをpropsとして受けて、また呼び出す構造が多いと思います。
今回はContextを使っているのでpropsではなく直接コンポーネントからaction dispatchしてみます。

import './TodoItem.css';
import { useTodosDispatch, Todo } from '../contexts/TodosContext';

type TodoItemProps = {
  todo: Todo;
};

function TodoItem({ todo }: TodoItemProps) {
  const dispatch = useTodosDispatch();

  const onToggle = () => {
    dispatch({
      type: 'TOGGLE',
      id: todo.id
    });
  };

  const onRemove = () => {
    dispatch({
      type: 'REMOVE',
      id: todo.id
    });
  };

  return (
    <li className={`TodoItem ${todo.done ? 'done' : ''}`}>
      <span className="text" onClick={onToggle}>
        {todo.text}
      </span>
      <span className="remove" onClick={onRemove}>
        (X)
      </span>
    </li>
  );
}

export default TodoItem;

まとめ

Context APIを使ってStateを管理する時はuseReducerを使ってState専用とDispatch専用Contextを作って使うと便利です。Contextを作った後Contextをより便利に使うためのカスタムHooksまで作るとさらに楽な開発になれると思います。

Discussion