Oct 17, 2025
•
6 min read
Redux for a boolean? Context provider hell? There's a better way.
I needed to show a notification badge in the header when data changed deep in the app. My options: Redux (too much setup), Context (re-render issues), or prop drilling (no thanks).
Jotai solved it in 5 minutes. It's useState but global, without the ceremony.
npm install jotai
That's it. No store, no configuration.
// atoms/notifications.ts
import { atom } from 'jotai';
export const hasNewNotificationsAtom = atom(false);
In the header component:
// components/Header.tsx
'use client';
import { useAtom } from 'jotai';
import { hasNewNotificationsAtom } from '@/atoms/notifications';
export const Header = () => {
const [hasNewNotifications] = useAtom(hasNewNotificationsAtom);
return (
<div>
<NotificationIcon />
{hasNewNotifications && <Badge>New!</Badge>}
</div>
);
};
Deep in some other component:
// components/SomeRandomComponent.tsx
'use client';
import { useSetAtom } from 'jotai';
import { hasNewNotificationsAtom } from '@/atoms/notifications';
export const DataFetcher = () => {
const setHasNewNotifications = useSetAtom(hasNewNotificationsAtom);
useEffect(() => {
// When new data comes in
setHasNewNotifications(true);
}, [newData]);
return <div>...</div>;
};
Done. No provider, no setup. Only the header re-renders when the atom changes.
// atoms/notifications.ts
import { atom } from 'jotai';
export const notificationsAtom = atom([]);
// Derived atom - automatically updates when notifications change
export const notificationCountAtom = atom(
(get) => get(notificationsAtom).length
);
export const hasNewNotificationsAtom = atom(
(get) => get(notificationCountAtom) > 0
);
Now in your header:
'use client';
import { useAtomValue } from 'jotai';
import { notificationCountAtom } from '@/atoms/notifications';
export const Header = () => {
const count = useAtomValue(notificationCountAtom);
return (
<div>
<NotificationIcon />
{count > 0 && <Badge>{count}</Badge>}
</div>
);
};
Auto-updates when notifications change. No useEffect needed.
// atoms/user.ts
import { atom } from 'jotai';
export const userIdAtom = atom(null);
export const userDataAtom = atom(async (get) => {
const userId = get(userIdAtom);
if (!userId) return null;
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
Using it:
'use client';
import { useAtomValue, useSetAtom } from 'jotai';
import { Suspense } from 'react';
import { userIdAtom, userDataAtom } from '@/atoms/user';
function UserProfile() {
const userData = useAtomValue(userDataAtom); // This suspends while loading
return <div>{userData.name}</div>;
}
export const App = () => {
const setUserId = useSetAtom(userIdAtom);
return (
<div>
<button onClick={() => setUserId(123)}>Load User</button>
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
</div>
);
};
When userId changes, it refetches automatically. Suspense handles loading.
// atoms/actions.ts
import { atom } from 'jotai';
import { userDataAtom } from './user';
export const refreshUserAtom = atom(
null, // no read value
async (get, set) => {
const response = await fetch('/api/users/current');
const data = await response.json();
set(userDataAtom, data); // Update another atom
}
);
Using it:
'use client';
import { useSetAtom } from 'jotai';
import { refreshUserAtom } from '@/atoms/actions';
export const RefreshButton = () => {
const refreshUser = useSetAtom(refreshUserAtom);
return (
<button onClick={() => refreshUser()}>
Refresh User Data
</button>
);
};
Redux actions without Redux.
atoms/
├── user.ts // User-related state
├── notifications.ts // Notification state
├── filters.ts // Filter state for lists
└── ui.ts // UI state (modals, sidebars, etc.)
Each file exports multiple related atoms:
// atoms/filters.ts
import { atom } from 'jotai';
export const searchQueryAtom = atom('');
export const selectedCategoryAtom = atom('all');
export const sortOrderAtom = atom('desc');
// Derived atom that combines all filters
export const filtersAtom = atom((get) => ({
search: get(searchQueryAtom),
category: get(selectedCategoryAtom),
sort: get(sortOrderAtom),
}));
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Automatically syncs with localStorage
export const themeAtom = atomWithStorage('theme', 'light');
export const userPreferencesAtom = atomWithStorage('preferences', {
emailNotifications: true,
darkMode: false,
});
Auto-syncs with localStorage. Restores on reload.
Server state: Use React Query instead.
// ❌ Don't do this with Jotai
const usersAtom = atom(async () => {
const response = await fetch('/api/users');
return response.json();
});
// ✅ Do this with React Query
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
Forms: Use React Hook Form.
Everything: Don't replace all state. Use it for shared client state.
// atoms/modals.ts
import { atom } from 'jotai';
type ModalType = 'edit-user' | 'delete-confirm' | 'settings' | null;
export const activeModalAtom = atom<ModalType>(null);
export const modalDataAtom = atom<any>(null);
// Helper atoms for specific modals
export const isEditUserModalOpenAtom = atom(
(get) => get(activeModalAtom) === 'edit-user'
);
export const openEditUserModalAtom = atom(
null,
(get, set, userId: number) => {
set(modalDataAtom, { userId });
set(activeModalAtom, 'edit-user');
}
);
export const closeModalAtom = atom(
null,
(get, set) => {
set(activeModalAtom, null);
set(modalDataAtom, null);
}
);
Using it anywhere:
'use client';
import { useSetAtom } from 'jotai';
import { openEditUserModalAtom } from '@/atoms/modals';
export const UserRow = ({ userId }) => {
const openEditModal = useSetAtom(openEditUserModalAtom);
return (
<tr>
<td>User {userId}</td>
<td>
<button onClick={() => openEditModal(userId)}>
Edit
</button>
</td>
</tr>
);
};
And the modal:
'use client';
import { useAtomValue, useSetAtom } from 'jotai';
import {
isEditUserModalOpenAtom,
modalDataAtom,
closeModalAtom
} from '@/atoms/modals';
export const EditUserModal = () => {
const isOpen = useAtomValue(isEditUserModalOpenAtom);
const data = useAtomValue(modalDataAtom);
const closeModal = useSetAtom(closeModalAtom);
if (!isOpen) return null;
return (
<Modal onClose={closeModal}>
<h2>Edit User {data.userId}</h2>
{/* Modal content */}
</Modal>
);
};
No prop drilling, no providers.
Create atoms outside components:
// ❌ Don't do this
const MyComponent = () => {
const myAtom = atom(0); // Creates a new atom on every render
// ...
};
// ✅ Do this
const myAtom = atom(0); // Created once, outside component
const MyComponent = () => {
const [value] = useAtom(myAtom);
// ...
};
Use DevTools: npm install jotai-devtools to see all atoms and debug.
It's the middle ground between useState (too local) and Redux (too much ceremony).
Start small. Pick one feature using prop drilling or context. Move it to Jotai. If you like it, use it more. If not, no big deal.
For me, it's now my default for shared client state. Simple to explain, fast to implement, easy to maintain.
© 2025 Lucas Nogueira. All rights reserved.
Built with Next.js & Material-UI