🏭

TODO アプリで useReducer + useContext 入門

16 min read

はじめに

React Hooks と TypeScript で簡単 TODO アプリ」の続編です。

https://zenn.dev/sprout2000/articles/60cc8f1aa08b4b

前回までの TODO

useReducer の導入

https://ja.reactjs.org/docs/hooks-reference.html#usereducer

useReducer は useState の代替品で、複数の値にまたがる複雑な state ロジックがある場合や、前の state に基づいて次の state を決める必要がある場合に有用。

1. useReducer の構文

const [state, dispatch] = useReducer(reducer, 'ステートの初期値');
  • reducer はステートを更新するための関数
  • dispatchreducer を実行するための呼び出し関数

2. dispatch による reducer メソッドの呼び出し

従前は setState(newState) を実行していたところを以下のようにステートを更新する。

dispatch(action);

action は識別子 type プロパティと値のプロパティで構成されたオブジェクトで、そのアクションが何をするのかを示す。具体的には次のように利用される。

// count ステートを +1 する
dispatch({ type: 'add', value: state.count + 1 });

/*
 * 以下と(ほぼ)同義
 * setCount((count) => count + 1);
 */

3. ステートの型を定義する

この TODO アプリでは3つのステートが使われているので、それぞれの型を定義しておく。

src/index.tsx
interface Todo {
  value: string;
  id: number;
  checked: boolean;
  removed: boolean;
}

type Filter = 'all' | 'checked' | 'unchecked' | 'removed';

interface State {
  text: string;
  todos: Todo[];
  filter: Filter;
}

4. ステートの初期値を設定する

上の型定義を利用して各ステートの初期値を設定する。

src/index.tsx
interface State {
  text: string;
  todos: Todo[];
  filter: Filter;
}

const initialState: State = {
  text: '',
  todos: [],
  filter: 'all',
};

5. action の要件を検討する

従前の TODO アプリでステートを更新しているコールバック関数は以下の7つ。

handleOnChange: (e: string) => void;
handleOnSubmit: (e: Event) => void;
handleOnEdit: (id: number, value: string) => void;
handleOnCheck: (id: number, checked: boolean) => void;
handleOnRemove: (id: number, removed: boolean) => void;
handleOnFilter: (e: Event) => void;
handleOnEmpty: () => void;

これらを置き換えるような action を揃えてゆく。

handleOnChangehandleOnFilter

src/index.tsx
type Action =
  | { type: 'change'; value: string }
  | { type: 'filter'; value: Filter };

handleOnSubmithandleOnEmpty を追加

src/index.tsx
type Action =
  | { type: 'change'; value: string }
  | { type: 'filter'; value: Filter };
  | { type: 'submit' }
  | { type: 'empty' };

handleOnEdit, handleOnCheck, handleOnRemove を追加

src/index.tsx
type Action =
  | { type: 'change'; value: string }
  | { type: 'filter'; value: Filter };
  | { type: 'submit' }
  | { type: 'empty' }
  | { type: 'edit'; id: number; value: string }
  | { type: 'check'; id: number; checked: boolean }
  | { type: 'remove'; id: number; removed: boolean };

6. ステートを更新する reducer メソッドを作成

reducer メソッド の型

(state: State, action: Action) => newState: State

State 型と Action 型を引数として受け取り、新しいステートを返す。

実装

src/index.tsx
const reducer = (state: State, action: Action): State => {};

上記7つのコールバック関数を Action の中へ落とし込んでゆく。

src/index.tsx
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'change': {
      return { ...state, text: action.value };
    }
    case 'submit': {
      if (!state.text) return state;

      const newTodo: Todo = {
        value: state.text,
        id: new Date().getTime(),
        checked: false,
        removed: false,
      };
      return { ...state, todos: [newTodo, ...state.todos], text: '' };
    }
    case 'filter':
      return { ...state, filter: action.value };
    case 'edit': {
      const newTodos = state.todos.map((todo) => {
        if (todo.id === action.id) {
          todo.value = action.value;
        }
        return todo;
      });
      return { ...state, todos: newTodos };
    }
    case 'check': {
      const newTodos = state.todos.map((todo) => {
        if (todo.id === action.id) {
          todo.checked = !action.checked;
        }
        return todo;
      });
      return { ...state, todos: newTodos };
    }
    case 'remove': {
      const newTodos = state.todos.map((todo) => {
        if (todo.id === action.id) {
          todo.removed = !action.removed;
        }
        return todo;
      });
      return { ...state, todos: newTodos };
    }
    case 'empty': {
      const newTodos = state.todos.filter((todo) => !todo.removed);
      return { ...state, todos: newTodos };
    }
    default:
      return state;
  }
};

7. useStateuseReducer へ置き換え

useReducer のインポート

src/index.tsx
- import React, { useState } from 'react';
+ import React, { useReducer } from 'react';

useState フックたちを削除し、 reducer メソッドと初期ステートによって statedispatch を作成。

src/index.tsx
const App: React.VFC = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

8. 7つのコールバック関数の setHogedispatch へ置き換える

  • handleOnChage
src/index.tsx
  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch({ type: 'change', value: e.target.value });
  };
  • handleOnSubmit
src/index.tsx
  const handleOnSubmit = (
    e: React.FormEvent<HTMLFormElement | HTMLInputElement>
  ) => {
    e.preventDefault();
    dispatch({ type: 'submit' });
  };
  • handleOnEdit
src/index.tsx
  const handleOnEdit = (id: number, value: string) => {
    dispatch({ type: 'edit', id, value });
  };
  • handleOnCheck
src/index.tsx
  const handleOnCheck = (id: number, checked: boolean) => {
    dispatch({ type: 'check', id, checked });
  };
  • handleOnRemove
src/index.tsx
  const handleOnRemove = (id: number, removed: boolean) => {
    dispatch({ type: 'remove', id, removed });
  };
  • handleOnFilter
src/index.tsx
  const handleOnFilter = (e: React.ChangeEvent<HTMLSelectElement>) => {
    dispatch({ type: 'filter', value: e.target.value as Filter });
  };
  • handleOnEmpty
src/index.tsx
  const handleOnEmpty = () => {
    dispatch({ type: 'empty' });
  };

9. 関数内や JSX 内でステートを参照している部分を state.hoge へ修正

src/index.tsx
  const filteredTodos = state.todos.filter((todo) => {
    switch (state.filter) {
      case 'all':
        return !todo.removed;
      case 'checked':
        return !todo.removed && todo.checked;
      case 'unchecked':
        return !todo.removed && !todo.checked;
      case 'removed':
        return todo.removed;
      default:
        return todo;
    }
  });

  return (
    <div className="container">
      <select className="select" defaultValue="all" onChange={handleOnFilter}>
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">現在のタスク</option>
        <option value="removed">ごみ箱</option>
      </select>
      {state.filter === 'removed' ? (
        <button className="empty" onClick={handleOnEmpty}>
          ごみ箱を空にする
        </button>
      ) : (
        <form className="form" onSubmit={handleOnSubmit}>
          <input
            className="text"
            type="text"
            disabled={state.filter === 'checked'}
            value={state.text}
            onChange={handleOnChange}
          />
          <input
            className="button"
            type="submit"
            disabled={state.filter === 'checked'}
            value="追加"
            onSubmit={handleOnSubmit}
          />
        </form>
      )}
      <ul>

10. 各部をコンポーネントに切り出して memo 化する

  • コンポーネントを memo 化すると props に変化がない限り再計算されないため、パフォーマンスが向上する(こともある)
  • dispatchprops として渡すことで各コンポーネントからのステート更新も可能
src/index.tsx
- import React, { useReducer } from 'react';
+ import React, { useReducer, memo, Dispatch } from 'react';

Selector コンポーネント

src/index.tsx
const Selector: React.VFC<{ dispatch: Dispatch<Action> }> = memo(
  ({ dispatch }) => {
    const handleOnFilter = (e: React.ChangeEvent<HTMLSelectElement>) => {
      dispatch({ type: 'filter', value: e.target.value as Filter });
    };

    return (
      <select className="select" defaultValue="all" onChange={handleOnFilter}>
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">現在のタスク</option>
        <option value="removed">ごみ箱</option>
      </select>
    );
  }
);
Selector.displayName = 'Selector';

EmptyButton コンポーネント

src/index.tsx
const EmptyButton: React.VFC<{ dispatch: Dispatch<Action> }> = memo(
  ({ dispatch }) => {
    const handleOnEmpty = () => {
      dispatch({ type: 'empty' });
    };

    return (
      <button className="empty" onClick={handleOnEmpty}>
        ごみ箱を空にする
      </button>
    );
  }
);
EmptyButton.displayName = 'EmptyButton';

Form コンポーネント

src/index.tsx
const Form: React.VFC<{ state: State; dispatch: Dispatch<Action> }> = memo(
  ({ state, dispatch }) => {
    const handleOnSubmit = (
      e: React.FormEvent<HTMLFormElement | HTMLInputElement>
    ) => {
      e.preventDefault();
      dispatch({ type: 'submit' });
    };

    const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      dispatch({ type: 'change', value: e.target.value });
    };

    return (
      <form className="form" onSubmit={handleOnSubmit}>
        <input
          className="text"
          type="text"
          disabled={state.filter === 'checked'}
          value={state.text}
          onChange={handleOnChange}
        />
        <input
          className="button"
          type="submit"
          disabled={state.filter === 'checked'}
          value="追加"
          onSubmit={handleOnSubmit}
        />
      </form>
    );
  }
);
Form.displayName = 'Form';

FilteredTodos コンポーネント

src/index.tsx
const FilteredTodos: React.VFC<{
  state: State;
  dispatch: Dispatch<Action>;
}> = memo(({ state, dispatch }) => {
  const handleOnEdit = (id: number, value: string) => {
    dispatch({ type: 'edit', id, value });
  };

  const handleOnCheck = (id: number, checked: boolean) => {
    dispatch({ type: 'check', id, checked });
  };

  const handleOnRemove = (id: number, removed: boolean) => {
    dispatch({ type: 'remove', id, removed });
  };

  const filteredTodos = state.todos.filter((todo) => {
    switch (state.filter) {
      case 'all':
        return !todo.removed;
      case 'checked':
        return !todo.removed && todo.checked;
      case 'unchecked':
        return !todo.removed && !todo.checked;
      case 'removed':
        return todo.removed;
      default:
        return todo;
    }
  });

  return (
    <ul>
      {filteredTodos.map((todo) => {
        return (
          <li key={todo.id}>
            <input
              type="checkbox"
              disabled={todo.removed}
              checked={todo.checked}
              onChange={() => handleOnCheck(todo.id, todo.checked)}
            />
            <input
              className="text"
              type="text"
              disabled={todo.checked || todo.removed}
              value={todo.value}
              onChange={(e) => handleOnEdit(todo.id, e.target.value)}
            />
            <button
              className="button"
              onClick={() => handleOnRemove(todo.id, todo.removed)}>
              {todo.removed ? '復元' : '削除'}
            </button>
          </li>
        );
      })}
    </ul>
  );
});
FilteredTodos.displayName = 'FilteredTodos';

親コンポーネント

src/index.tsx
const App: React.VFC = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div className="container">
      <Selector dispatch={dispatch} />
      {state.filter === 'removed' ? (
        <EmptyButton dispatch={dispatch} />
      ) : (
        <Form state={state} dispatch={dispatch} />
      )}
      <FilteredTodos state={state} dispatch={dispatch} />
    </div>
  );
};

useContext の導入

典型的な React アプリケーションでは、データは props を通して親から子、そして孫へと順々に渡されるが、useContext フック を利用することで、ツリーの各階層で明示的にプロパティを渡すことなく、コンポーネント間でこれらの値を共有できる。

画像引用元: React hooks を基礎から理解する (useContext 編)

https://ja.reactjs.org/docs/context.html

1. コンテキストの作成

createContextuseContext をインポート

src/index.tsx
import React, {
  useReducer,
  memo,
  Dispatch,
  createContext,
  useContext,
} from 'react';

createContext の構文

const MyContext = React.createContext(defaultValue);

2. AppContext の作成

ここでは、前項では props として各コンポーネントへ渡す必要があった StateDispatch を保持するコンテキストとする。

src/index.tsx
const AppContext = createContext(
  {} as {
    state: State;
    dispatch: Dispatch<Action>;
  }
);

3. Context の提供元(=親コンポーネント)での設定 (1)

return 文JSXAppContext.Provider でラップする。

src/index.tsx
const App: React.VFC = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <AppContext.Provider value={{ state, dispatch }}>
      <div className="container">
        <Selector dispatch={dispatch} />
        {state.filter === 'removed' ? (
          <EmptyButton dispatch={dispatch} />
        ) : (
          <Form state={state} dispatch={dispatch} />
        )}
        <FilteredTodos state={state} dispatch={dispatch} />
      </div>
    </AppContext.Provider>
  );
};

4. Context の提供元(=親コンポーネント)での設定 (2)

従前の props に代わって、 AppContext.Providerstate の値や dispatch メソッドを提供するため、各コンポーネントへの props は削除する。

src/index.tsx
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      <div className="container">
        <Selector />
        {state.filter === 'removed' ? (
          <EmptyButton />
        ) : (
          <Form />
        )}
        <FilteredTodos />
      </div>
    </AppContext.Provider>
  );

5. Context の提供を受ける各コンポーネントでの設定

useContext の構文

コンテクストオブジェクト(React.createContext からの戻り値)を受け取り、そのコンテクストの現在値を返す。

const value = useContext('コンテキストオブジェクト');

useContext フックの引数に AppContext を与えることで、そこから提供されるコンテキスト (= State, Dispatch) を利用できるので props は削除する。

index.tsx
const Form: React.VFC = memo(() => {
  const { state, dispatch } = useContext(AppContext);

  const handleOnSubmit = (
    e: React.FormEvent<HTMLFormElement | HTMLInputElement>
  ) => {
    e.preventDefault();
    dispatch({ type: 'submit' });
  };

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch({ type: 'change', value: e.target.value });
  };

  return (
    <form className="form" onSubmit={handleOnSubmit}>
      <input
        className="text"
        type="text"
        disabled={state.filter === 'checked'}
        value={state.text}
        onChange={handleOnChange}
      />
      <input
        className="button"
        type="submit"
        disabled={state.filter === 'checked'}
        value="追加"
        onSubmit={handleOnSubmit}
      />
    </form>
  );
});

ここまでの TODO

Discussion

ログインするとコメントできます