Introduction
If you are dealing with state update logic, using Context-API is an initially good way to go and inject a state into a nested tree of components. However, there are cases when managing state based on Context can lead to unwanted re-renders in your components. This article has the intention to show you an approach to avoid unnecessary re-renders in your React components if you are managing a piece of UI state based on Context.
An Initial Example
Imagine you are reading data from a Todos
context, that would look something like:
import { createContext, Dispatch, PropsWithChildren, useReducer } from "react";
export type TodoDTO = {
description: string;
};
export type TodosState = Array<TodoDTO>;
export type TodosStatePayloadAction = { type: "add_todo"; payload: string }; // | other payloads format here ...;
export type TodosContextValue = {
todos: TodoDTO[];
dispatch: Dispatch<TodosStatePayloadAction>;
};
export const TodosContext = createContext<TodosContextValue>({
todos: [],
dispatch: () => null,
});
export function TodosProvider({ children }: PropsWithChildren) {
const [state, dispatch] = useReducer(todosReducer, []);
return (
<TodosContext.Provider value={{ todos: state, dispatch }}>
{children}
</TodosContext.Provider>
);
}
function todosReducer(
state: TodosState,
action: TodosStatePayloadAction
): TodosState {
switch (action.type) {
case "add_todo": {
return [...state, { description: action.payload }];
}
//other operations below ...
default: {
return [...state];
}
}
}
The Problematic
However, the code above is totally fine if you have just a small tree of components, in the scenario where you have tons of components reading from the same Context, whenever the value state in to-dos reducer change, all the components reading from it will also re-render, even though those components who do not necessarily read the value in todos
but only the dispatch
function.
Take a look in the example:
export function SearchBar() {
const { dispatch } = useContext(TodosContext);
const textValue = useRef<HTMLInputElement | null>(null);
function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
e.preventDefault();
if (!textValue.current?.value) return;
dispatch({ type: "add_todo", payload: textValue.current.value });
textValue.current.value = "";
}
return (
<form onSubmit={handleSubmit}>
<input ref={textValue} />
</form>
);
}
In the example above, even though SearchBar
is only reading the dispatch
function from TodosContext
, because the state todos
and dispatch
are attached together in the same Context, SearchBar
will re-render again when a new to-do is added.
Now imagine this scenario where instead of a simple todo
state and dispatch
, you have some more complex logic to deal with, and many components are reading from it. It would cause many components to re-render again, even if they are not necessarily dependent on the state value from Context.
Removing The Unintended Behavior
To fix this behavior, we can take an approach where we split the TodosContext
into two. One Context for state injection into the component tree and another Context for the dispatch/handlers.
Let's see an example:
export const TodosContextState = createContext<TodosState>([]);
export const TodosContextDispatch = createContext<
Dispatch<TodosStatePayloadAction>
>(() => null);
export function TodosProvider({ children }: PropsWithChildren) {
const [state, dispatch] = useReducer(todosReducer, []);
return (
<TodosContextState.Provider value={state}>
<TodosContextDispatch.Provider value={dispatch}>
{children}
</TodosContextDispatch.Provider>
</TodosContextState.Provider>
);
}
In our new implementation, we split the previous Context in two, so we are injecting to-dos state via TodosContextState
and the dispatch function via TodosContextDispatch
, in the new implementation, when our SearchBar
component submit a new to-do, only the components that are reading from TodosContextState
will re-render and not all the components that are reading just a piece of data from it like SearchBar
that only read the dispatch
function.
We only need to do a final adjustment and update the Context in our SearchBar
component from TodosContext
to TodosContextDispatch
and update our components that are only reading the state value from TodosContext
to TodosContextState
, so it would be:
export function SearchBar() {
const dispatch = useContext(TodosContextDispatch);
const textValue = useRef<HTMLInputElement | null>(null);
function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
e.preventDefault();
if (!textValue.current?.value) return;
dispatch({ type: "add_todo", payload: textValue.current.value });
textValue.current.value = "";
}
return (
<form onSubmit={handleSubmit}>
<input ref={textValue} />
</form>
);
}
Conclusion
The example is this article was trivial just for the sake of understanding, but the main lesson that you should leave with is that:
- Splitting a single Context into a
state
/dispatch
version is a good way to prevent unintended re-renders if you have components that only read a single piece of it and are not concerned about the whole state structure.
Top comments (1)
Awesome explanation for state management using pure React!