4
2

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 3 years have passed since last update.

UbiregiAdvent Calendar 2020

Day 2

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

Last updated at Posted at 2020-12-02

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

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

コンテクスト - React

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

<div>
 <p>hoge</p>
</div>
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>
}

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

Context APIを使用した例:CodeSandbox

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

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

Reduxとの併用:CodeSandbox

おわり

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

参考

4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?