State Management
State management pitfalls in frontend applications and systematic solutions
State Management Troubleshooting
State management is the backbone of interactive frontend applications. Mismanaged state leads to UI inconsistencies, performance degradation, and hard-to-debug behaviors.
1. Prop Drilling — Deep Component Coupling
Problem
Passing state through many layers of components that don't use it:
// 5 levels deep just to pass `theme`
<App theme={theme}>
<Layout theme={theme}>
<Sidebar theme={theme}>
<Navigation theme={theme}>
<NavItem theme={theme} /> {/* Finally used here */}
</Navigation>
</Sidebar>
</Layout>
</App>- Intermediate components become coupled to data they don't use
- Refactoring is painful — changing prop names requires updating every layer
- Difficult to trace data flow
Solution
React Context for cross-cutting concerns:
const ThemeContext = createContext<Theme | null>(null);
function useTheme() {
const theme = useContext(ThemeContext);
if (!theme) throw new Error('useTheme must be used within ThemeProvider');
return theme;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// NavItem directly accesses theme — no prop drilling
function NavItem() {
const { theme } = useTheme();
return <div className={theme}>...</div>;
}Component Composition pattern:
// Instead of drilling props, compose components
function App() {
const [user, setUser] = useState<User | null>(null);
return (
<Layout>
<Sidebar>
<UserProfile user={user} /> {/* Pass directly to consumer */}
</Sidebar>
</Layout>
);
}
// Layout and Sidebar don't need to know about `user`
function Layout({ children }: { children: React.ReactNode }) {
return <div className="layout">{children}</div>;
}2. Unnecessary Re-renders from State Changes
Problem
function App() {
const [user, setUser] = useState({ name: 'Alice', preferences: { theme: 'dark' } });
const updateTheme = (theme: string) => {
// Mutating state directly — React won't detect change
user.preferences.theme = theme;
setUser(user); // Same reference — no re-render
};
return <Settings user={user} onThemeChange={updateTheme} />;
}Or conversely, too many re-renders:
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState({});
// Every state update re-renders the entire component tree
}Root Cause
- Direct state mutation (React relies on reference equality)
- Too much state in a single component
- Context value changes causing all consumers to re-render
Solution
Immutable state updates:
const updateTheme = (theme: string) => {
setUser(prev => ({
...prev,
preferences: { ...prev.preferences, theme },
}));
};Use Immer for complex nested updates:
import { produce } from 'immer';
const updateTheme = (theme: string) => {
setUser(produce(draft => {
draft.preferences.theme = theme; // Mutate draft safely
}));
};Split state by update frequency:
function SearchPage() {
// UI state — changes frequently
const [query, setQuery] = useState('');
// Data state — changes less often, can be separated
const { results, isLoading, error } = useSearchResults(query);
// Pagination — independent concern
const { page, nextPage, prevPage } = usePagination();
return (/* ... */);
}Selective context subscriptions with useSyncExternalStore or state libraries:
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
theme: 'light',
setTheme: (theme) => set({ theme }),
setUser: (user) => set({ user }),
}));
// Only re-renders when `theme` changes
function ThemeToggle() {
const theme = useStore((state) => state.theme);
const setTheme = useStore((state) => state.setTheme);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}3. Stale Closures in Hooks
Problem
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0 — stale closure
setCount(count + 1); // Always sets to 1 — stale closure
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps — captures initial `count` forever
return <div>{count}</div>;
}Root Cause
JavaScript closures capture variables at creation time. When useEffect runs with [] deps, the callback captures the initial count = 0 forever.
Solution
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // ✓ Functional update — no stale closure
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}For accessing latest value without re-subscribing:
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function EventHandler() {
const [count, setCount] = useState(0);
const countRef = useLatest(count);
useEffect(() => {
const handler = () => {
console.log(countRef.current); // Always latest value
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []); // Safe — ref is stable
}4. Race Conditions in Async State Updates
Problem
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // May set stale data if userId changed
}, [userId]);
}If userId changes rapidly (e.g., 1 → 2 → 3), responses may arrive out of order, displaying user 1's data when user 3 is expected.
Solution
AbortController for cancellation:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // Cancel on cleanup
}, [userId]);
}Using TanStack Query (recommended for data fetching):
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
staleTime: 5 * 60 * 1000,
});
}5. Global State Overuse — "Everything in Redux" Anti-Pattern
Problem
Putting all state in a global store:
// Redux store with EVERYTHING
const store = {
user: { ... },
theme: 'dark',
modalOpen: false, // UI state — should be local
formValues: { ... }, // Form state — should be local
tooltipVisible: true, // Ephemeral UI state
scrollPosition: 0, // Derived from DOM
searchResults: [...], // Server cache
};- Performance: Every state change triggers selectors across the app
- Complexity: Actions, reducers, selectors for trivial state
- Testing: Components can't be tested in isolation
Solution: State Classification
| State Type | Examples | Recommended Tool |
|---|---|---|
| Local UI | Modal open, tooltip visible, form input | useState, useReducer |
| Shared UI | Theme, locale, sidebar collapsed | Context, Zustand, Jotai |
| Server Cache | API responses, paginated data | TanStack Query, SWR |
| URL State | Search params, filters, pagination | useSearchParams, nuqs |
| Form State | Validation, dirty fields, submit status | React Hook Form, Formik |
| Global App | Auth state, feature flags, permissions | Zustand, Redux Toolkit |
function SearchPage() {
// URL state
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';
// Server cache
const { data, isLoading } = useQuery({
queryKey: ['search', query],
queryFn: () => searchAPI(query),
});
// Local UI state
const [isFilterOpen, setFilterOpen] = useState(false);
// Global state — only for truly global concerns
const user = useStore(state => state.user);
}6. Context Re-Render Cascade
Problem
const AppContext = createContext<{
user: User;
theme: Theme;
notifications: Notification[];
}>({ /* ... */ });
// ANY change in user, theme, OR notifications re-renders ALL consumers
function NotificationBadge() {
const { notifications } = useContext(AppContext); // Also re-renders on theme change!
}Solution: Split Contexts by Update Frequency
// Separate contexts by how often they change
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>('light');
const NotificationContext = createContext<Notification[]>([]);
function Providers({ children }: { children: React.ReactNode }) {
return (
<UserProvider>
<ThemeProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</ThemeProvider>
</UserProvider>
);
}Or use useMemo to stabilize context values:
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<Theme>('light');
const userValue = useMemo(() => ({ user, setUser }), [user]);
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<UserContext.Provider value={userValue}>
<ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
</UserContext.Provider>
);
}7. Form State Complexity
Problem
Managing form state manually leads to boilerplate and bugs:
function RegistrationForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false);
// ... 20+ state variables for a complex form
}Solution: Use React Hook Form + Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8).regex(/[A-Z]/, 'Must contain uppercase'),
});
type FormData = z.infer<typeof schema>;
function RegistrationForm() {
const { register, handleSubmit, formState: { errors, isSubmitting, isDirty } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await api.register(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Register</button>
</form>
);
}Summary: State Management Decision Tree
Is the state used by only one component?
├─ Yes → useState / useReducer
└─ No
├─ Is it server data?
│ └─ Yes → TanStack Query / SWR
├─ Is it URL/routing state?
│ └─ Yes → useSearchParams / URL state library
├─ Is it form state?
│ └─ Yes → React Hook Form / Formik
├─ Is it shared by a few nearby components?
│ └─ Yes → Lift state up / Context
└─ Is it truly global?
└─ Yes → Zustand / Redux Toolkit / Jotai