ReactのContext APIを使ってCallbackのバケツリレーをやめる

この記事は Ubiregi Advent Calendar 2020 2日目のエントリです。

ReactのContext APIを使っていますか？reduxを使用していることもあって、自分はあんまり使ってなかったですが。

[コンテクスト - React](https://ja.reactjs.org/docs/context.html)

## なぜ私はContext APIを使用したのか

Context APIを使用した理由は二つあります

一つ目はあるComponent群で頻繁に使用/更新される値があり、トップレベルのComponentからその値と値と更新する関数を子Componentにバケツリレーするのをやめたかったから。

二つ目は値をstateをhooks管理した場合、stateを作成するhooks内に更新するためのcallbackが増え、巨大なhooksが出来上がってしまった。

reduxのstoreに突っ込んで管理しても良かったのですが、アプリケーションレベルで必要な値ではなく、あくまでも限定されたComponent群の中で頻繁に使用/更新されるだけだったので、使用/更新箇所を限定できるContext APIを選択しました。

## Context API使用しない場合の例と、何をやめたかったのか

Context APIを使わない場合はこんな感じになると思います。createTodo,deleteTodoなるCallbackを子Componentの `<TodoForm/>` と `<TodoDeleteButton/>` に渡しています。これをやめたかった。

```html
<div>
 <p>hoge</p>
</div>
```

```tsx
export const TodoList = () => {
  const { todos, createTodo, deleteTodo } = useTodos()

  return (
    <>
      <h2>TODO一覧</h2>
      {todos.map((todo, index) => (
        <div key={index}>
          <Todo name={todo.name} dueDate={todo.dueDate} />
					// deleteTodoを渡すのをやめたい
          <TodoDeleteButton deleteTodo={deleteTodo(index)} />
        </div>
      ))}
			// createTodoを渡すのをやめたい
      <TodoForm createTodo={createTodo} />
    </>
  )
}
```

useTodosは以下のようになっています。stateを作成しているため、stateを更新するためのCallback(createTodo,deleteTodo)が二つ並んでいますが、両者は同じstateを更新するという点以外はお互いに関心がないため、分割したい。

useStateを使っていますが、useReducerでも概ね同じようなコードになると思います。

```tsx
const useTodos = () => {
	// mock object
  const todosMock = [
    { name: '掃除', dueDate: new Date('2021-07-14T00:00:00') },
    { name: '皿洗い', dueDate: new Date('2020-07-13T00:00:00') },
    { name: '犬を洗う', dueDate: new Date('2020-07-15T00:00:00') },
  ]
  const [todos, setTodos] = useState(todosMock)

  const createTodo = useCallback(
    (name: string, dueDate: string) => {
      setTodos(todos.concat({ name, dueDate: new Date(dueDate) }))
    },
    [todos],
  )

  const deleteTodo = useCallback(
    (deleteIndex: number) => () => {
      setTodos(todos.filter((_todo, i) => i !== deleteIndex))
    },
    [todos],
  )
  return { todos, createTodo, deleteTodo }
}
```

## Context APIを使ってCallbackの受け渡しを避ける

Context APIを使用して、各Componentから値の更新を行うようにします。

こちらは雑に作ったContextです。defaultValueが思いつかないのでPartialを使用してOptionalにし、空の値をセットしています。いいのかなこれ。他の方法あったら教えてください。

```tsx
type Todo = { name: string; dueDate: Date }

const TodoContext: React.Context<Partial<{
  todos: Todo[]
  setTodos: React.Dispatch<React.SetStateAction<Todo[]>>
}>> = createContext({})
```

じゃあContextの初期値はどこでセットするのよというと、このようなProviderを作成してその中で値と値を更新する関数をセットします。useStateでもuseReaducerでもいいのですが、useReducerを使用した例は結構あったので、ここではuseStateを使っています。

```tsx
export const TodoProvider: React.FC<{
  children: React.ReactNode
}> = ({ children }) => {
  const todosMock = [
    { name: '掃除', dueDate: new Date('2021-07-14T00:00:00') },
    { name: '皿洗い', dueDate: new Date('2020-07-13T00:00:00') },
    { name: '犬を洗う', dueDate: new Date('2020-07-15T00:00:00') },
  ]
  const [todos, setTodos] = useState<Todo[]>(todosMock)
  return (
    <TodoContext.Provider value={{ todos, setTodos }}>
      {children}
    </TodoContext.Provider>
  )
}
```

そしてContextから値と値の更新関数を引き出すhookを作ります。値がない場合はErrorでも投げましょう。

```tsx
export const useTodos = () => {
  const { todos, setTodos } = useContext(TodoContext)
  if (!todos || !setTodos) {
    throw new Error('Context has no value.')
  }
  return { todos, setTodos }
}
```

## Context APIを使用した例

前段で作成したContext APIを使用した場合、`<TodoForm/>` と `<TodoDeleteButton/>` にCallbackを渡さずに済んでいます。deleteTodoIndexとかいうpropsが出てきたりしていますが......。

```tsx
export const TodoList = () => {
  const { todos } = useTodos()

  return (
    <>
      <h2>TODO一覧</h2>
      {todos.map((todo, index) => (
        <div key={index}>
          <Todo name={todo.name} dueDate={todo.dueDate} />
          <TodoDeleteButton deleteTodoIndex={index} />
        </div>
      ))}
      <TodoForm />
    </>
  )
}
```

子Componentの`<TodoDeleteButton />`はこのようになっています。`Todo削除のCallback`のみを提供するhooksに分割できています。

```tsx
const useDeleteTodo = (index: number) => {
  const { todos, setTodos } = useTodos()
  return useCallback(() => {
    setTodos(todos.filter((_todo, i) => i !== index))
  }, [todos, setTodos, index])
}

export const TodoDeleteButton: React.FC<{ deleteTodoIndex: number }> = ({
  deleteTodoIndex,
}) => {
  const deleteTodo = useDeleteTodo(deleteTodoIndex)
  return <button onClick={deleteTodo}>削除</button>
}
```

全体のコードはこちらから参照できますので、気になる方はご覧ください。

[Context APIを使用した例:CodeSandbox](https://codesandbox.io/s/black-dew-hw2wr)

## Reduxとの併用

reduxでグローバルなstateを管理して、Context APIで管理する値はグローバルなstateから作りたい場合はProviderに処理を書きます。useEffectを使用して、グローバルなstateの変更をContextに反映するようにします。

```tsx
export const TodoProvider: React.FC<{
  children: React.ReactNode
}> = ({ children }) => {
  const initialTodo = useTodosGlobal()
  const [todos, setTodos] = useState<Todo[]>(initialTodo)
  
  useEffect(() => {
    setTodos(initialTodo)
  }, [initialTodo])

  return (
    <TodoContext.Provider value={{ todos, setTodos }}>
      {children}
    </TodoContext.Provider>
  )
}
```

`useTodosGlobal`の中身はこうなっています。Providerのstateが無限に更新され続けないように、useMemoを使用しましょう。Maximum update depth exceeded...つって怒られます。

```tsx
type TodoState = { name: string, dueDate: string }

export const useTodosGlobal = () => {
  const todoState = useSelector((state: { todos: TodoState[] }) => state.todos)
  return useMemo(() => todoState.map(todo => ({ ...todo, dueDate: new Date(todo.dueDate) })), [todoState])
}
```

全体のコードはこちらです。

[Reduxとの併用:CodeSandbox](https://codesandbox.io/s/zen-water-itjti)

## おわり

以上のようにContext APIを使用してみました。Callbackのバケツリレーをやめれたし、hooksの分割もできるようになりました。
デメリットは今のところ調査しきれていないです。余計な再レンダリングが起こりうる気がするので、この辺は別で調べて機会があれば書いてみます。なんか起きてそうな予感がする。

## 参考
https://ja.reactjs.org/docs/hooks-faq.html#how-to-avoid-passing-callbacks-down