Back to posts

My first time using Context API (properly): focus on re-render optimization

This one describes a Context refactor on a B2B app I'm working on. The goal was to gradually work up to a Context setup that is clear, organized, and most importantly ensures good re-render optimization practices.


1st stage - the beginner pitfall

My initial setup for the Context API was: MenuContext in App.tsx wraps all children (Header, RouterProvider, and everything under the router). The state for it was defined outside of the context wrapper, in App():

1function App() {
2 const [isMenuOpen, setIsMenuOpen] = useState(false);
3
4 router.subscribe('onResolved', () => {
5 setIsMenuOpen(false);
6 });
7
8 return (
9 <MenuContext value={{ isMenuOpen, setIsMenuOpen }}>
10 <Header />
11 <RouterProvider router={router} />
12 </MenuContext>
13 );
14}

It worked. But I later found out that this is a typical beginner's mistake - a pitfall regarding defining state like this. When the state is defined outside, in App(), all the components that are wrapped in MenuContext will re-render when isMenuOpen state changes - even the ones that are not using that state! The root layout route was consuming menu state too (nav + backdrop), but plenty of page content under the router never touched it and still got dragged through another render pass.


2nd stage - fixing the mistake

Instead of wrapping components in MenuContext with the state living in App(), I made a custom MenuContextProvider component, and defined the state in it (this is the most important part); then return MenuContext.Provider wrapping the {children}.

State setting here + composition pattern - the provider owns the state, App just nests UI inside it:

1export const MenuContextProvider = ({ children }: MenuContextProviderProps) => {
2
3 const [isMenuOpen, setIsMenuOpen] = useState(false);
4
5 return (
6 <MenuContext.Provider value={{ isMenuOpen, setIsMenuOpen }}>{children}</MenuContext.Provider>
7 );
8};
1function App() {
2 return (
3 <>
4 <MenuContextProvider>
5 <div className='bg-gray-100 min-h-screen'>
6 <Header />
7 <RouterProvider router={router} />
8 </div>
9 </MenuContextProvider>
10 </>
11 );
12}

What got better: App stopped re-rendering when I toggled the menu. Components that actually use the context still do - that's expected.

In other words, where you put useState decides how big the blast radius is.


3rd stage - tidier consumers (hooks)

The way I've been using Context after 2nd stage was working, but it could be refactored a little more to make the components using it tidier.

Before actually consuming context, we're supposed to make a check and throw an error if the context is for some reason unavailable - it also makes TypeScript not scream at you. I was doing that in every component that was using the context. But there's a better pattern: in the context file, wrap each context in a custom hook and do the check in there, instead of all over components using it. After that, just return the values from the hook. That way, in any component that needs that context, instead of 5-6 lines with the check, we can replace it with one line.

1export const useMenuContext = () => {
2 const context = useContext(MenuContext);
3 if (!context) {
4 throw new Error('useMenuContext must be used within a MenuContextProvider');
5 }
6 return context;
7};
1// Header.tsx - after
2const { isMenuOpen, setIsMenuOpen } = useMenuContext();

Worth saying explicitly: custom hooks did not change re-render rules for me. Same consumers, same subscriptions - stage 3 is organization, guardrails, and nicer call sites. I also applied the same pattern to dark theme context (useDarkContext) once that grew alongside the menu work.

Now I'm happy how my Context turned out.