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

Created: October 30, 2020 4:20 AM
Last Edited: December 08, 2020 3:04 PM

💡
この記事はUbiregi Advent Calendar 2020 2日目のために書かれたものです。同じ内容の記事をQiitaにも投稿しています。
 

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

loading

なぜ私は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/> に渡しています。これをやめたかった。

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でも概ね同じようなコードになると思います。

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にし、空の値をセットしています。いいのかなこれ。他の方法あったら教えてください。

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を使っています。

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でも投げましょう。

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が出てきたりしていますが......。

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に分割できています。

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>
}

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

loading

Reduxとの併用

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

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...つって怒られます。

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])
}

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

loading

おわり

以上のようにContext APIを使用してみました。Callbackのバケツリレーをやめれたし、hooksの分割もできるようになりました。

デメリットは今のところ調査しきれていないです。余計な再レンダリングが起こってしまっている気がするので、この辺は別で調べて機会があれば書いてみます。


<-

<<-