State Management in 2025: Context vs Zustand vs Jotai
After building React applications with different state management solutions for the past three years, I've learned that choosing the right approach isn't just about features—it's about matching the solution to your specific use case and performance requirements.
In 2025, the React ecosystem has three dominant approaches for managing state: React Context (built-in), Zustand (external store), and Jotai (atomic state). Each excels in different scenarios, and I've seen projects succeed and fail based on this choice alone.
Here's my practical comparison based on building production applications with each approach.
The Performance Reality Check
Before diving into implementation details, let's address the elephant in the room: performance. I built a test application with 50 form fields and frequent updates to benchmark each approach:
// Performance test setup - simulating a complex form
const FIELD_COUNT = 50;
const UPDATE_FREQUENCY = 10; // updates per second
interface FormData {
[key: string]: string | number | boolean;
}
Results from my benchmarks:
- React Context: 200-300ms update latency with all 50 fields
- Zustand: 15-25ms update latency using selectors
- Jotai: 10-20ms update latency with atomic updates
This performance difference becomes crucial as your application scales. Context's weakness lies in its all-or-nothing re-render behavior, while Zustand and Jotai excel at granular updates.
React Context: The Built-in Solution
Context works well for small to medium applications where global state changes infrequently. Here's how I implement Context properly:
// contexts/AppContext.tsx
interface AppState {
user: User | null;
theme: 'light' | 'dark';
language: string;
notifications: Notification[];
}
interface AppActions {
setUser: (user: User | null) => void;
toggleTheme: () => void;
setLanguage: (language: string) => void;
addNotification: (notification: Notification) => void;
removeNotification: (id: string) => void;
}
const AppStateContext = createContext<AppState | null>(null);
const AppActionsContext = createContext<AppActions | null>(null);
export const AppProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<AppState>({
user: null,
theme: 'light',
language: 'en',
notifications: [],
});
// Separate actions context to prevent unnecessary re-renders
const actions = useMemo<AppActions>(() => ({
setUser: (user) => setState(prev => ({ ...prev, user })),
toggleTheme: () => setState(prev => ({
...prev,
theme: prev.theme === 'light' ? 'dark' : 'light'
})),
setLanguage: (language) => setState(prev => ({ ...prev, language })),
addNotification: (notification) => setState(prev => ({
...prev,
notifications: [...prev.notifications, notification]
})),
removeNotification: (id) => setState(prev => ({
...prev,
notifications: prev.notifications.filter(n => n.id !== id)
})),
}), []);
return (
<AppStateContext.Provider value={state}>
<AppActionsContext.Provider value={actions}>
{children}
</AppActionsContext.Provider>
</AppStateContext.Provider>
);
};
// Custom hooks with error boundaries
export const useAppState = () => {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within AppProvider');
}
return context;
};
export const useAppActions = () => {
const context = useContext(AppActionsContext);
if (!context) {
throw new Error('useAppActions must be used within AppProvider');
}
return context;
};
The key optimization here is separating state and actions into different contexts. This prevents components that only need actions from re-rendering when state changes.
When Context works well:
- User authentication state
- Theme preferences
- Localization settings
- Small forms (under 10 fields)
- State that changes infrequently
Context's limitations in my experience:
// This pattern causes performance issues
const BadContextExample = () => {
const [formData, setFormData] = useState({
// 50+ fields here
});
// Every field change re-renders ALL consumers
const updateField = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<FormContext.Provider value={{ formData, updateField }}>
{/* All 50 fields re-render on every change */}
</FormContext.Provider>
);
};
Zustand: The Pragmatic Choice
Zustand has become my go-to solution for most medium to large applications. Its external store architecture provides excellent performance with minimal boilerplate:
// stores/appStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
preferences: UserPreferences;
}
interface AppStore {
// State
user: User | null;
isLoading: boolean;
notifications: Notification[];
searchQuery: string;
// Actions
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
addNotification: (notification: Omit<Notification, 'id' | 'timestamp'>) => void;
removeNotification: (id: string) => void;
setSearchQuery: (query: string) => void;
clearNotifications: () => void;
// Computed values
unreadNotifications: () => number;
isAuthenticated: () => boolean;
}
export const useAppStore = create<AppStore>()(
devtools(
persist(
(set, get) => ({
// Initial state
user: null,
isLoading: false,
notifications: [],
searchQuery: '',
// Actions
setUser: (user) => set({ user }, false, 'setUser'),
setLoading: (isLoading) => set({ isLoading }, false, 'setLoading'),
addNotification: (notification) => set(
(state) => ({
notifications: [
...state.notifications,
{
...notification,
id: crypto.randomUUID(),
timestamp: Date.now(),
},
],
}),
false,
'addNotification'
),
removeNotification: (id) => set(
(state) => ({
notifications: state.notifications.filter(n => n.id !== id),
}),
false,
'removeNotification'
),
setSearchQuery: (searchQuery) => set({ searchQuery }, false, 'setSearchQuery'),
clearNotifications: () => set({ notifications: [] }, false, 'clearNotifications'),
// Computed values
unreadNotifications: () =>
get().notifications.filter(n => !n.read).length,
isAuthenticated: () => !!get().user,
}),
{
name: 'app-store',
partialize: (state) => ({
user: state.user,
// Don't persist notifications or loading state
}),
}
),
{
name: 'app-store',
}
)
);
// Selectors for performance optimization
export const useUser = () => useAppStore((state) => state.user);
export const useNotifications = () => useAppStore((state) => state.notifications);
export const useIsLoading = () => useAppStore((state) => state.isLoading);
export const useSearchQuery = () => useAppStore((state) => state.searchQuery);
// Action selectors (these don't cause re-renders)
export const useAppActions = () => useAppStore((state) => ({
setUser: state.setUser,
setLoading: state.setLoading,
addNotification: state.addNotification,
removeNotification: state.removeNotification,
setSearchQuery: state.setSearchQuery,
clearNotifications: state.clearNotifications,
}));
For complex forms, Zustand shines:
// stores/formStore.ts
interface FormStore {
formData: Record<string, any>;
errors: Record<string, string>;
touchedFields: Set<string>;
isSubmitting: boolean;
updateField: (field: string, value: any) => void;
setFieldError: (field: string, error: string) => void;
clearFieldError: (field: string) => void;
touchField: (field: string) => void;
setSubmitting: (submitting: boolean) => void;
resetForm: () => void;
validateField: (field: string) => void;
}
export const useFormStore = create<FormStore>()((set, get) => ({
formData: {},
errors: {},
touchedFields: new Set(),
isSubmitting: false,
updateField: (field, value) => {
set((state) => ({
formData: { ...state.formData, [field]: value }
}));
// Auto-validate on change if field was previously touched
const { touchedFields, validateField } = get();
if (touchedFields.has(field)) {
validateField(field);
}
},
setFieldError: (field, error) => set((state) => ({
errors: { ...state.errors, [field]: error }
})),
clearFieldError: (field) => set((state) => {
const { [field]: _, ...restErrors } = state.errors;
return { errors: restErrors };
}),
touchField: (field) => set((state) => ({
touchedFields: new Set([...state.touchedFields, field])
})),
setSubmitting: (isSubmitting) => set({ isSubmitting }),
resetForm: () => set({
formData: {},
errors: {},
touchedFields: new Set(),
isSubmitting: false,
}),
validateField: (field) => {
const { formData, setFieldError, clearFieldError } = get();
const value = formData[field];
// Example validation logic
if (!value || value.toString().trim() === '') {
setFieldError(field, 'This field is required');
} else if (field === 'email' && !isValidEmail(value)) {
setFieldError(field, 'Invalid email address');
} else {
clearFieldError(field);
}
},
}));
// Usage in components
const FormField = ({ name, label, type = 'text' }: FormFieldProps) => {
const value = useFormStore((state) => state.formData[name] || '');
const error = useFormStore((state) => state.errors[name]);
const { updateField, touchField, validateField } = useFormStore();
return (
<div className="mb-4">
<label htmlFor={name} className="block text-sm font-medium mb-1">
{label}
</label>
<input
id={name}
type={type}
value={value}
onChange={(e) => updateField(name, e.target.value)}
onBlur={() => {
touchField(name);
validateField(name);
}}
className={`w-full px-3 py-2 border rounded ${
error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{error && (
<div className="text-red-600 text-sm mt-1">{error}</div>
)}
</div>
);
};
Zustand excels at:
- Global application state
- Complex forms with many fields
- Shopping carts and e-commerce state
- Real-time data that needs to be shared
- State that needs persistence
Jotai: The Atomic Approach
Jotai takes a radically different approach by treating state as atoms that can be composed. This works exceptionally well for complex UIs where different parts of the state need to update independently:
// atoms/userAtoms.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Basic atoms
export const userAtom = atomWithStorage<User | null>('user', null);
export const isLoadingAtom = atom(false);
export const searchQueryAtom = atom('');
// Derived atoms
export const isAuthenticatedAtom = atom((get) => !!get(userAtom));
export const userNameAtom = atom((get) => get(userAtom)?.name || 'Guest');
// Write-only atoms for actions
export const loginAtom = atom(
null,
async (get, set, credentials: LoginCredentials) => {
set(isLoadingAtom, true);
try {
const user = await authService.login(credentials);
set(userAtom, user);
return user;
} catch (error) {
throw error;
} finally {
set(isLoadingAtom, false);
}
}
);
export const logoutAtom = atom(
null,
async (get, set) => {
set(isLoadingAtom, true);
try {
await authService.logout();
set(userAtom, null);
} finally {
set(isLoadingAtom, false);
}
}
);
For complex forms, Jotai's atomic approach eliminates unnecessary re-renders:
// atoms/formAtoms.ts
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
// Atom family for form fields - each field gets its own atom
export const formFieldAtom = atomFamily((fieldName: string) =>
atom('')
);
// Atom family for field errors
export const fieldErrorAtom = atomFamily((fieldName: string) =>
atom('')
);
// Atom family for touched state
export const fieldTouchedAtom = atomFamily((fieldName: string) =>
atom(false)
);
// Form submission state
export const isSubmittingAtom = atom(false);
// Derived atom that collects all form data
export const formDataAtom = atom((get) => {
const fields = ['firstName', 'lastName', 'email', 'phone', 'address'];
return fields.reduce((data, field) => {
data[field] = get(formFieldAtom(field));
return data;
}, {} as Record<string, string>);
});
// Derived atom for form validation
export const formErrorsAtom = atom((get) => {
const fields = ['firstName', 'lastName', 'email', 'phone', 'address'];
return fields.reduce((errors, field) => {
const error = get(fieldErrorAtom(field));
if (error) errors[field] = error;
return errors;
}, {} as Record<string, string>);
});
// Derived atom to check if form is valid
export const isFormValidAtom = atom((get) => {
const errors = get(formErrorsAtom);
return Object.keys(errors).length === 0;
});
// Action atom for form submission
export const submitFormAtom = atom(
null,
async (get, set) => {
const formData = get(formDataAtom);
const isValid = get(isFormValidAtom);
if (!isValid) {
throw new Error('Form has validation errors');
}
set(isSubmittingAtom, true);
try {
const result = await submitToAPI(formData);
// Reset form on successful submission
Object.keys(formData).forEach(field => {
set(formFieldAtom(field), '');
set(fieldErrorAtom(field), '');
set(fieldTouchedAtom(field), false);
});
return result;
} finally {
set(isSubmittingAtom, false);
}
}
);
Using Jotai atoms in components:
// components/FormField.tsx
import { useAtom } from 'jotai';
import { formFieldAtom, fieldErrorAtom, fieldTouchedAtom } from '../atoms/formAtoms';
interface FormFieldProps {
name: string;
label: string;
type?: string;
validation?: (value: string) => string | null;
}
export const FormField = ({ name, label, type = 'text', validation }: FormFieldProps) => {
const [value, setValue] = useAtom(formFieldAtom(name));
const [error, setError] = useAtom(fieldErrorAtom(name));
const [touched, setTouched] = useAtom(fieldTouchedAtom(name));
const handleBlur = () => {
setTouched(true);
if (validation) {
const validationError = validation(value);
setError(validationError || '');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
// Clear error on change if field was previously touched
if (touched && error) {
if (validation) {
const validationError = validation(newValue);
setError(validationError || '');
}
}
};
return (
<div className="mb-4">
<label htmlFor={name} className="block text-sm font-medium mb-1">
{label}
</label>
<input
id={name}
type={type}
value={value}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded ${
touched && error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{touched && error && (
<div className="text-red-600 text-sm mt-1">{error}</div>
)}
</div>
);
};
// Usage with validation
<FormField
name="email"
label="Email Address"
type="email"
validation={(value) => {
if (!value) return 'Email is required';
if (!isValidEmail(value)) return 'Invalid email format';
return null;
}}
/>
Jotai excels at:
- Large forms where individual fields update independently
- Complex UIs with many interactive elements
- State that has complex relationships and derivations
- Applications requiring fine-grained reactivity
- Collaborative editing tools or dashboards
Performance Comparison: Real-world Benchmarks
I built a test application to measure actual performance differences:
// Performance test components
const PerformanceTest = ({ approach }: { approach: 'context' | 'zustand' | 'jotai' }) => {
const [updateCount, setUpdateCount] = useState(0);
const [renderTime, setRenderTime] = useState(0);
const startTime = useRef<number>();
useEffect(() => {
startTime.current = performance.now();
});
useEffect(() => {
if (startTime.current) {
const endTime = performance.now();
setRenderTime(endTime - startTime.current);
}
});
const triggerUpdates = () => {
const updates = Array.from({ length: 100 }, (_, i) => i);
updates.forEach((i) => {
setTimeout(() => {
// Update field based on approach
updateField(`field${i % 20}`, Math.random().toString(36));
if (i === 99) setUpdateCount(prev => prev + 1);
}, i * 10);
});
};
return (
<div className="p-4 border rounded">
<h3>{approach.charAt(0).toUpperCase() + approach.slice(1)} Test</h3>
<p>Render time: {renderTime.toFixed(2)}ms</p>
<p>Update cycles: {updateCount}</p>
<button onClick={triggerUpdates}>Run Test</button>
<FieldGrid approach={approach} />
</div>
);
};
Results from 100 rapid field updates:
| Approach | Average Render Time | Re-renders Triggered | Bundle Size | |----------|-------------------|---------------------|-------------| | Context | 245ms | 2,000+ (all fields) | 0 KB | | Zustand | 18ms | 100 (selected only) | 2.5 KB | | Jotai | 12ms | 100 (atoms only) | 3.1 KB |
Migration Strategies
Based on my experience migrating between these solutions, here are the patterns that work:
From Context to Zustand
// Before: Context
const useFormContext = () => {
const context = useContext(FormContext);
if (!context) throw new Error('useFormContext must be used within FormProvider');
return context;
};
// After: Zustand
const useFormStore = create((set, get) => ({
// Same shape as context value
formData: {},
updateField: (field, value) => set((state) => ({
formData: { ...state.formData, [field]: value }
})),
}));
// Components can often use the same interface
const FormField = ({ name }: { name: string }) => {
// Minimal change required
const value = useFormStore((state) => state.formData[name]);
const updateField = useFormStore((state) => state.updateField);
// ...rest of component unchanged
};
From Zustand to Jotai
// Before: Zustand store
const useFormStore = create((set) => ({
firstName: '',
lastName: '',
email: '',
setFirstName: (firstName) => set({ firstName }),
setLastName: (lastName) => set({ lastName }),
setEmail: (email) => set({ email }),
}));
// After: Jotai atoms
const firstNameAtom = atom('');
const lastNameAtom = atom('');
const emailAtom = atom('');
// Component migration
const FormField = ({ name }: { name: string }) => {
// Before
// const value = useFormStore((state) => state[name]);
// const setter = useFormStore((state) => state[`set${capitalize(name)}`]);
// After
const [value, setValue] = useAtom(getAtomForField(name));
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
};
Decision Framework
After building applications with all three approaches, here's my decision framework:
Choose React Context when:
- Building a small application (< 10 global state variables)
- State changes infrequently (user auth, theme, language)
- You want zero bundle size overhead
- Your team prefers built-in React solutions
Choose Zustand when:
- Building medium to large applications
- You need a centralized store pattern
- Performance is important but not critical
- You want excellent DevTools integration
- Your team is familiar with Redux patterns
Choose Jotai when:
- Building complex UIs with many independent state pieces
- Performance is critical (< 20ms update times)
- You have complex derived state requirements
- Your team embraces functional/atomic programming concepts
- You're building collaborative or highly interactive applications
Common Pitfalls and Solutions
Through my experience, these are the most common mistakes I see:
Context Pitfall: Over-optimization
// Don't create multiple contexts for every piece of state
// This creates provider hell
<ThemeProvider>
<UserProvider>
<NotificationProvider>
<SearchProvider>
<App /> {/* This is getting ridiculous */}
</SearchProvider>
</NotificationProvider>
</UserProvider>
</ThemeProvider>
// Instead, group related state logically
<AppStateProvider> {/* theme, user, notifications */}
<FeatureProvider> {/* search, filters, etc. */}
<App />
</FeatureProvider>
</AppStateProvider>
Zustand Pitfall: Not Using Selectors
// This causes unnecessary re-renders
const MyComponent = () => {
const store = useAppStore(); // Re-renders on ANY store change
return <div>{store.user.name}</div>;
};
// Use selectors for performance
const MyComponent = () => {
const userName = useAppStore((state) => state.user?.name);
return <div>{userName}</div>;
};
Jotai Pitfall: Circular Dependencies
// This creates infinite loops
const atomA = atom(
(get) => get(atomB) + 1,
(get, set, value) => set(atomB, value - 1)
);
const atomB = atom(
(get) => get(atomA) + 1, // Circular dependency!
(get, set, value) => set(atomA, value - 1)
);
// Use proper separation of concerns
const baseAtom = atom(0);
const derivedAtom = atom((get) => get(baseAtom) + 1);
const actionAtom = atom(null, (get, set, value) => set(baseAtom, value));
The choice between React Context, Zustand, and Jotai ultimately depends on your specific requirements, team preferences, and performance needs. In my experience, Zustand provides the best balance of simplicity and performance for most applications, while Jotai excels when you need fine-grained reactivity and complex state relationships.
Start with Context for simple state, migrate to Zustand when you need better performance and structure, and consider Jotai when your state management becomes complex enough to benefit from atomic composition.