この記事は Ubiregi Advent Calendar 2020 2日目のエントリです。
ReactのContext APIを使っていますか?reduxを使用していることもあって、自分はあんまり使ってなかったですが。
なぜ私は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>
}
全体のコードはこちらから参照できますので、気になる方はご覧ください。
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])
}
全体のコードはこちらです。
おわり
以上のようにContext APIを使用してみました。Callbackのバケツリレーをやめれたし、hooksの分割もできるようになりました。
デメリットは今のところ調査しきれていないです。余計な再レンダリングが起こりうる気がするので、この辺は別で調べて機会があれば書いてみます。なんか起きてそうな予感がする。
参考