How to Optimize React Context to Prevent Unnecessary Re‑renders
Learn how to effectively use React Context for state management, avoid props drilling, and eliminate redundant re‑renders by implementing a custom publish‑subscribe store, selector hooks, and performance optimizations similar to libraries like zustand.
In a React application, developers often use Context to manage state and solve props drilling issues. While Context simplifies data sharing across deep component trees, it can also cause unnecessary re‑renders when the context value changes.
Basic Usage of Context
According to the official React definition, Context provides a way to pass data through the component tree without having to pass props manually at every level.
Without Context, a typical component hierarchy would require repetitive prop passing, as shown in the following example:
// App
const [query, setQuery] = useState({
name: '',
team: '',
age: undefined,
score: undefined,
});
const handleChange = (val) => {
setQuery({ ...query, ...val });
};
// FormContainer
<FormContainer value={query} onChange={handleChange} />;
// ...This approach is verbose and hard to maintain. Using Context, the same data can be accessed and modified directly in any component without passing props through every intermediate layer.
// App
<AppContext.Provider value={state}>
<AppContainer />
</AppContext.Provider>;
// SearchForm
const [query, setQuery] = useContext(AppContext);
<Select
value={query.age}
onChange={(val) => {
setQuery({ age: val });
}}
/>Context Repeated Rendering Issue
Although Context reduces prop drilling, updating the state in the top‑level component causes the entire component tree to re‑render, even for components that do not depend on the changed context value.
React DevTools can highlight these unnecessary renders, as illustrated below:
When the name field changes, components that do not use name still re‑render, leading to a performance penalty.
Better Applying Context
To avoid the repeated rendering problem, only components that actually depend on the context should re‑render after a context update. This requires a data source that components can subscribe to, receiving notifications only when the data they care about changes.
A simple publish‑subscribe model can achieve this.
Publish‑Subscribe
A minimal implementation looks like this:
export type Listener<T> = (state: T) => void;
export type Store<T> = {
setState: (partial: Partial<T>) => void;
getState: () => T;
subscribe: (listener: Listener<T>) => () => void;
};
export function createStore<T>(initState: T): Store<T> {
let state = initState;
const listeners = new Set<Listener<T>>();
const setState = (partial: Partial<T>) => {
state = { ...state, ...partial };
listeners.forEach((listener) => listener(state));
};
const getState = () => state;
const subscribe = (listener: Listener<T>) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
}In a React app, we can create a Context that holds this store and provide it to the component tree.
Initialize Store and Pass via Context
export type QueryState = {
name?: string;
team?: string;
age?: string;
score?: string;
};
export const AppContext = createContext<Store<QueryState> | null>(null);
const initState: QueryState = {};
const store = createStore(initState);
function App() {
return (
<AppContext.Provider value={store}>
<AppContainer />
</AppContext.Provider>
);
}We then create a custom hook that subscribes to the store and updates component state when the store changes.
Hook for Subscription
const useAppStore = (): [QueryState, (p: Partial<QueryState>) => void] => {
const storeCtx = useContext(AppContext)!;
const [state, setState] = useState(storeCtx.getState());
// add subscribe
useEffect(() => {
const unsubscribe = storeCtx.subscribe((s: QueryState) => {
setState(s);
});
return () => unsubscribe();
}, []);
// In React 18 you could use useSyncExternalStore instead
return [state, storeCtx.setState];
};Components can now obtain or modify state via this hook:
function SearchForm() {
const [query, setQuery] = useAppStore();
// ...
}After applying this pattern, only components that subscribe to the relevant slice of state re‑render, as demonstrated by the following DevTools snapshot:
Performance Optimization
Components like AgeSelect and ScoreSelect only need specific fields from query. We can enhance the hook with an optional selector function so that a component receives only the data it cares about.
const useAppStore = (
selector?: (state: QueryState) => any,
): [Partial<QueryState>, (p: Partial<QueryState>) => void] => {
const storeCtx = useContext(AppContext)!;
const defaultSelectState = selector
? selector(storeCtx.getState())
: storeCtx.getState();
const [state, setState] = useState(defaultSelectState);
// add subscribe
useEffect(() => {
const unsubscribe = storeCtx.subscribe((s: QueryState) => {
const selectState = selector ? selector(s) : s;
setState(selectState);
});
return () => unsubscribe();
}, []);
return [state, storeCtx.setState];
};Using the selector, a component re‑renders only when the selected piece of state changes:
const ScoreSelect = () => {
const [score] = useAppStore((state) => state.score);
// ...
};The rendering effect is shown below:
Final Code Organization
// createStoreContext
import { createStore, Store } from './createStore';
function createStoreContext<T>(initState: T) {
const StoreContext = createContext<Store<T> | null>(null);
const StoreProvider = ({ children }: PropsWithChildren) => {
const storeRef = useRef<Store<T>>();
if (!storeRef.current) {
storeRef.current = createStore(initState);
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
};
const useStore = (
selector?: (state: T) => any,
): [Partial<T>, (p: Partial<T>) => void] => {
const storeCtx = useContext(StoreContext)!;
const defaultSelectState = selector
? selector(storeCtx.getState())
: storeCtx.getState();
const [state, setState] = useState(defaultSelectState);
useEffect(() => {
const unsubscribe = storeCtx.subscribe((s: T) => {
const selectState = selector ? selector(s) : s;
setState(selectState);
});
return () => unsubscribe();
}, []);
return [state, storeCtx.setState];
};
return { StoreProvider, useStore };
} // AppContext
export type QueryState = {
name?: string;
team?: string;
age?: string;
score?: string;
};
const initState: QueryState = {
name: '',
team: '',
age: undefined,
score: undefined,
};
const { StoreProvider: AppContextProvider, useStore: useAppStore } =
createStoreContext(initState);
export { AppContextProvider, useAppStore }; // App component
import { AppContextProvider, useAppStore } from './AppContext';
function App() {
return (
<AppContextProvider>
<AppContainer />
</AppContextProvider>
);
}
const ScoreSelect = () => {
const [score, setQuery] = useAppStore((state) => state.score);
// ...
};Summary
By implementing a lightweight publish‑subscribe store and passing it through React Context, components can subscribe to specific slices of state, trigger re‑renders only when necessary, and avoid the performance drawbacks of default Context updates. This approach mirrors the core idea behind state‑management libraries such as zustand .
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
