Ripping out React Context for Jotai saved my app

Well, there I was, staring at the React DevTools profiler at 2 AM on a Thursday. Every time I pressed a single key in the main dashboard search bar, the entire node graph lit up yellow and red. 42 separate components were re-rendering per keystroke. The browser tab was quietly eating 1.4GB of RAM.

The app felt like wading through wet cement, to be honest.

I built this entire data visualization layer relying heavily on React Context. It seemed fine three months ago — just wrap the tree, pass down the values, call it a day. But I knew Context triggers an update on every consumer when the value changes. And I just didn’t think the tree would grow so fast that it would literally freeze the browser during fast typing.

I refused to rewrite everything in Redux. I mean, I don’t have the patience for that much boilerplate anymore. Zustand is usually my default choice, but this specific UI had hundreds of tiny, independent interactive widgets. Highly granular, relational data. Zustand’s single-store model felt a bit clunky for managing hundreds of isolated widget states, you know?

Going atomic

Javascript code on screen - Viewing complex javascript code on computer screen | Premium Photo
Javascript code on screen – Viewing complex javascript code on computer screen | Premium Photo

I finally gave up and pulled in Jotai 2.6.1. And if you haven’t used it, Jotai treats state as “atoms” rather than one giant object. You define a tiny piece of state anywhere. Components only subscribe to the specific atoms they read. It sounds exactly like Recoil, but — and this is key — unlike Recoil, Jotai isn’t effectively abandoned by its creators.

The migration was actually pretty fast. I deleted about 400 lines of nested Provider wrappers and replaced them with this basic pattern:

import { atom, useAtom, useAtomValue } from 'jotai';

// Define these literally anywhere outside your components
export const searchStringAtom = atom('');

// Derived state is where the magic actually happens
export const filteredWidgetsAtom = atom((get) => {
    const search = get(searchStringAtom).toLowerCase();
    const widgets = get(allWidgetsAtom);
    
    if (!search) return widgets;
    return widgets.filter(w => w.name.toLowerCase().includes(search));
});

// Inside the component
function SearchInput() {
    const [search, setSearch] = useAtom(searchStringAtom);
    return <input value={search} onChange={e => setSearch(e.target.value)} />;
}

The beauty here is that filteredWidgetsAtom automatically recalculates only when searchStringAtom or allWidgetsAtom changes. Components reading the filtered list only paint when the actual result array changes. And my search input was completely decoupled from the heavy charts below it.

The Next.js Server Component Gotcha

But here’s the massive trap I fell into. I’m running this on Next.js 15.1.2 using the App Router. And the Jotai documentation mentions this, but it’s buried just enough that I completely missed it on my first pass.

programmer coding late night - Free Late night coding Image - Technology, Programmer, Coding ...
programmer coding late night – Free Late night coding Image – Technology, Programmer, Coding …

If you use atoms without explicitly wrapping your tree in a Jotai <Provider>, the library falls back to a default, global store. And on a purely client-side React app, who cares — it works fine.

But on a server? That default store is shared across the entire Node process. I pushed my refactor to our staging cluster (running on three t3.medium instances) and asked a coworker to test it. And for about five terrifying minutes, User A’s search query was briefly hydrating on User B’s screen halfway across the country. State was bleeding across HTTP requests.

You absolutely must wrap your feature boundaries in a Provider if you are doing SSR or working with React Server Components. This forces Jotai to create a fresh, isolated store for that specific request and user session.

import { Provider } from 'jotai';

export default function DashboardLayout({ children }) {
  return (
    // This isolates the store per-request on the server
    <Provider>
      {children}
    </Provider>
  );
}

The final numbers

And once I fixed my SSR leak, the performance jump was actually ridiculous. I ran the exact same heavy dataset through the profiler.

Input latency dropped from a sluggish 180ms down to 4ms. We went from 42 component updates to exactly 1 (just the input box itself). The charts only repainted when the derived filter atom actually returned a new array, rather than every time the user hit the backspace key.

I still use Zustand for high-level app settings like auth state or dark mode preferences. It’s great for that. But for highly interactive, complex UI widgets that need to talk to each other without tearing down the DOM? Atoms are probably the way to go.

FAQ

Why does React Context cause so many re-renders in large component trees?

React Context triggers an update on every consumer whenever the context value changes. In the author’s dashboard, a single keystroke in the search bar caused 42 separate components to re-render and the browser tab to consume 1.4GB of RAM. Because Context propagates updates to the entire subscribed tree, it becomes a severe bottleneck once the tree grows large or contains many independent interactive widgets.

Why choose Jotai over Zustand for granular widget state?

Zustand uses a single-store model, which felt clunky for managing hundreds of isolated, relational widget states. Jotai treats state as atoms, tiny pieces defined anywhere, so components subscribe only to the specific atoms they read. The author still uses Zustand for high-level app settings like auth state and dark mode preferences, but switched to Jotai atoms for highly interactive, complex UI widgets that communicate without tearing down the DOM.

How do I prevent Jotai state from leaking across users in Next.js App Router?

If you use atoms without wrapping your tree in a Jotai Provider, the library falls back to a default global store shared across the entire Node process, causing state to bleed across HTTP requests. The author saw User A’s search query hydrate on User B’s screen. Wrap feature boundaries in a Provider during SSR or with React Server Components to force a fresh, isolated store per request and user session.

How much performance improvement did switching from React Context to Jotai actually give?

After the migration and fixing the SSR leak, input latency dropped from a sluggish 180ms to 4ms on the same heavy dataset. Component updates per keystroke went from 42 down to exactly 1, just the input box itself. Charts only repainted when the derived filter atom returned a new array, rather than on every backspace. The author also deleted about 400 lines of nested Provider wrappers.