State Management in Modern Applications: Choosing the Right Solution
State management might be the most over-engineered aspect of modern frontend development. I’ve seen teams adopt Redux for a simple todo app and others manage complex dashboards with just React hooks. Both extremes cause pain.
The truth? State management isn’t about choosing the “best” library. It’s about understanding your state, where it lives, and who needs it. Pick the simplest tool that solves your actual problem.
Understanding Your State
Before choosing a tool, categorize your state:
Server State
Data fetched from APIs. The source of truth is on the server.
Examples:
- User profiles
- Product listings
- Order history
- Analytics data
Characteristics:
- Asynchronous
- Can be stale
- Needs caching and synchronization
- Shared across components
Best Tools: React Query, SWR, Apollo Client (for GraphQL)
Client State
Data that only exists in the browser. The source of truth is the client.
Examples:
- Form input values
- UI state (modals, dropdowns, tabs)
- Selected items
- Filters and sorting preferences
Characteristics:
- Synchronous
- Ephemeral (doesn’t persist)
- Component-specific or app-wide
Best Tools: useState, useReducer, Context, Zustand, Jotai
Local State
State used by a single component.
Examples:
- Input field value
- Hover state
- Collapsed/expanded state
Best Tool: useState, useReducer
When to Use What
Local Component State: useState/useReducer
If only one component needs it, keep it local:
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleSearch = async () => {
const data = await searchAPI(query);
setResults(data);
};
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={handleSearch}>Search</button>
<ResultsList results={results} />
</div>
);
}
When to lift state up: When multiple components need the same state. But don’t lift prematurely!
Prop Drilling: React Context
When passing props through many layers becomes painful:
// Bad - prop drilling
function App() {
const [user, setUser] = useState(null);
return (
<Header user={user} />
<MainContent user={user}>
<Sidebar user={user}>
<UserProfile user={user} /> {/* Finally used here */}
</Sidebar>
</MainContent>
);
}
// Good - Context
const UserContext = createContext(null);
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={user}>
<Header />
<MainContent>
<Sidebar>
<UserProfile />
</Sidebar>
</MainContent>
</UserContext.Provider>
);
}
function UserProfile() {
const user = useContext(UserContext);
return <div>{user.name}</div>;
}
Context Limitations:
- Every context change rerenders all consumers
- No built-in optimization
- Fine for infrequent updates (theme, auth)
- Not great for frequent updates (form values, cursor position)
Server State: React Query or SWR
For data from APIs:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }) {
// Fetching
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
});
// Mutations
const queryClient = useQueryClient();
const updateUser = useMutation({
mutationFn: (updates) => api.updateUser(userId, updates),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return (
<div>
<h1>{data.name}</h1>
<button onClick={() => updateUser.mutate({ name: 'New Name' })}>
Update
</button>
</div>
);
}
Why React Query:
- Automatic caching
- Background refetching
- Stale-while-revalidate
- Request deduplication
- Offline support
Don’t put server state in Redux/Zustand. React Query does it better.
Complex Client State: Zustand
When Context rerenders are problematic and you need global client state:
import create from 'zustand';
// Define store
const useStore = create((set, get) => ({
// State
filters: {
category: 'all',
priceRange: [0, 1000],
inStock: false,
},
selectedItems: [],
// Actions
setFilter: (key, value) =>
set((state) => ({
filters: { ...state.filters, [key]: value },
})),
toggleItem: (id) =>
set((state) => {
const selected = state.selectedItems;
return {
selectedItems: selected.includes(id)
? selected.filter((item) => item !== id)
: [...selected, id],
};
}),
clearFilters: () =>
set({
filters: {
category: 'all',
priceRange: [0, 1000],
inStock: false,
},
}),
// Computed values
get hasActiveFilters() {
const filters = get().filters;
return (
filters.category !== 'all' ||
filters.priceRange[0] !== 0 ||
filters.priceRange[1] !== 1000 ||
filters.inStock
);
},
}));
// Use in components
function ProductFilters() {
const filters = useStore((state) => state.filters);
const setFilter = useStore((state) => state.setFilter);
return (
<div>
<select
value={filters.category}
onChange={(e) => setFilter('category', e.target.value)}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
</select>
</div>
);
}
function ProductList() {
// Only rerenders when selectedItems changes
const selectedItems = useStore((state) => state.selectedItems);
const toggleItem = useStore((state) => state.toggleItem);
return (
<div>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
selected={selectedItems.includes(product.id)}
onToggle={() => toggleItem(product.id)}
/>
))}
</div>
);
}
Zustand Advantages:
- Minimal boilerplate
- No providers needed
- Selective subscriptions (no unnecessary rerenders)
- Simple API
- Small bundle size (1kb)
Atomic State: Jotai
For fine-grained reactivity:
import { atom, useAtom } from 'jotai';
// Define atoms
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// Use atoms
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubleCount] = useAtom(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// Derived atoms
const userIdAtom = atom('123');
const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
return await fetchUser(userId);
});
function UserProfile() {
const [user] = useAtom(userAtom);
return <div>{user.name}</div>;
}
Jotai Advantages:
- Bottom-up approach (compose atoms)
- TypeScript-first
- Async support built-in
- Minimal rerenders
Redux: When You Actually Need It
Redux gets a bad rap, but it’s still the right choice for certain scenarios:
import { configureStore, createSlice } from '@reduxjs/toolkit';
// Define slice
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0,
},
reducers: {
addItem: (state, action) => {
const item = action.payload;
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
removeItem: (state, action) => {
state.items = state.items.filter((item) => item.id !== action.payload);
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
},
});
// Configure store
const store = configureStore({
reducer: {
cart: cartSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(logger, analytics),
});
// Use in components
function Cart() {
const dispatch = useDispatch();
const items = useSelector((state) => state.cart.items);
const total = useSelector((state) => state.cart.total);
return (
<div>
{items.map((item) => (
<div key={item.id}>
{item.name} x {item.quantity}
<button onClick={() => dispatch(cartSlice.actions.removeItem(item.id))}>
Remove
</button>
</div>
))}
<p>Total: ${total}</p>
</div>
);
}
Use Redux when:
- You need middleware (logging, analytics, persistence)
- You need time-travel debugging
- You have complex state logic with multiple actions
- You’re building a large app with many developers
Don’t use Redux when:
- Your app is small or medium-sized
- State is mostly server state
- You don’t need advanced features
Form State: Special Case
Forms deserve special attention:
Simple Forms: Controlled Components
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
await login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}
Complex Forms: React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18),
});
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
});
const onSubmit = async (data) => {
await signup(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit" disabled={isSubmitting}>
Sign Up
</button>
</form>
);
}
React Hook Form advantages:
- Minimal rerenders (uncontrolled inputs)
- Built-in validation
- TypeScript support
- Small bundle size
Persistence Strategies
State often needs to survive page refreshes:
Local Storage
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{
name: 'app-storage', // Key in localStorage
}
)
);
Session Storage
const useSessionStore = create(
persist(
(set) => ({
searchHistory: [],
addSearch: (query) =>
set((state) => ({
searchHistory: [query, ...state.searchHistory].slice(0, 10),
})),
}),
{
name: 'session-storage',
storage: createJSONStorage(() => sessionStorage),
}
)
);
IndexedDB for Large Data
import { openDB } from 'idb';
const dbPromise = openDB('my-app', 1, {
upgrade(db) {
db.createObjectStore('cache');
},
});
async function getCached(key) {
return (await dbPromise).get('cache', key);
}
async function setCached(key, value) {
return (await dbPromise).put('cache', value, key);
}
Performance Optimization
Memoization
Prevent unnecessary rerenders:
import { memo, useMemo, useCallback } from 'react';
const ExpensiveComponent = memo(({ data, onSelect }) => {
const processed = useMemo(() => {
return data.map((item) => expensiveTransform(item));
}, [data]);
return (
<div>
{processed.map((item) => (
<div key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</div>
))}
</div>
);
});
function Parent() {
const [selected, setSelected] = useState(null);
// Without useCallback, onSelect changes every render
const handleSelect = useCallback((id) => {
setSelected(id);
}, []);
return <ExpensiveComponent data={items} onSelect={handleSelect} />;
}
Selector Optimization
// Bad - creates new array every render, causes rerenders
const items = useSelector((state) =>
state.items.filter((item) => item.active)
);
// Good - use reselect for memoized selectors
import { createSelector } from 'reselect';
const selectActiveItems = createSelector(
[(state) => state.items],
(items) => items.filter((item) => item.active)
);
const items = useSelector(selectActiveItems);
Zustand Selective Subscription
// Bad - rerenders on any store change
const { filters, items, ui } = useStore();
// Good - only rerenders when filters change
const filters = useStore((state) => state.filters);
// Even better - shallow comparison for objects
import shallow from 'zustand/shallow';
const { category, priceRange } = useStore(
(state) => ({
category: state.filters.category,
priceRange: state.filters.priceRange,
}),
shallow
);
Testing State Management
Testing Zustand Stores
import { renderHook, act } from '@testing-library/react';
describe('useStore', () => {
beforeEach(() => {
useStore.setState({ count: 0 }, true); // Reset store
});
it('increments count', () => {
const { result } = renderHook(() => useStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Testing with React Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
it('fetches user data', async () => {
const { result } = renderHook(() => useUser('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: '123', name: 'Alice' });
});
Common Pitfalls
Over-Globalizing State
Not everything needs to be global:
// Bad - global state for modal
const useModalStore = create((set) => ({
isOpen: false,
openModal: () => set({ isOpen: true }),
closeModal: () => set({ isOpen: false }),
}));
// Good - local state
function MyComponent() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<button onClick={() => setIsModalOpen(true)}>Open</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</>
);
}
Mixing Server and Client State
// Bad - mixing concerns
const useStore = create((set) => ({
user: null, // Server state
theme: 'light', // Client state
fetchUser: async () => {
const user = await api.getUser();
set({ user });
},
}));
// Good - separate concerns
const { data: user } = useQuery(['user'], api.getUser); // Server state
const theme = useStore((state) => state.theme); // Client state
Premature Optimization
// Don't do this unless you have actual performance problems
const MemoizedEverything = memo(({ a, b, c, d, e }) => {
const result1 = useMemo(() => a + b, [a, b]);
const result2 = useMemo(() => c + d, [c, d]);
const handler = useCallback(() => doSomething(e), [e]);
return <div onClick={handler}>{result1 + result2}</div>;
});
// Just do this
function SimpleComponent({ a, b, c, d, e }) {
return <div onClick={() => doSomething(e)}>{a + b + c + d}</div>;
}
Decision Framework
Here’s how to choose:
Local component state → useState/useReducer
Prop drilling → Context (infrequent updates) or lift state up
Server data → React Query or SWR
Global client state → Zustand or Jotai
Complex logic + middleware → Redux Toolkit
Forms → React Hook Form
Real-time sync → Zustand + WebSockets or Replicache
Conclusion
State management isn’t about tools—it’s about understanding where state lives and who needs access. Start with the simplest solution and only add complexity when the simple solution fails.
React’s built-in hooks solve 80% of state management needs. React Query handles most server state. For the remaining client state, Zustand provides global state with minimal boilerplate.
Redux still has its place in large applications with complex workflows, but most teams overuse it. Choose tools that match your team’s skill level and your app’s actual complexity.
Struggling with state management in your application? VooStack helps teams architect scalable frontend applications with the right state management patterns. Let’s chat about your specific needs.