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
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.
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.











