React State Management in 2025: What You Actually Need
Learn how to manage state in modern React apps. Understand what is remote, URL, local, and shared state, and when you actually need a state management library. We'll cover Immer for immutable updates, Zustand for shared state, TanStack Query for remote data, and more.
🧠 Understanding State Concerns
Forget everything you thought you knew about Redux! 🤯 Most React apps don't need a massive state library. The secret sauce? Break your state into categories and pick the right tool for each. It's like using the right utensil for each course of a meal — you wouldn't eat soup with a fork!
• Remote State: Data from APIs, databases → Use TanStack Query or SWR
• URL State: Query parameters, routing → Use nuqs or router hooks
• Local State: Component-specific state → Use useState or useReducer
• Shared State: State shared across components → Use Zustand or Context (sparingly)
🔴 Impact: CRITICAL — This mental model changes EVERYTHING about how you think about state — get it right and 90% of your problems vanish
📋 In this section: State Categories • Remote vs Local vs URL vs Shared • Tool Selection Guide
Key Insight: ~90% of state management problems disappear when you use the right tool for each concern. Only ~10% of state actually needs a shared state management library.
✨ Unit 74: Immutable Updates with useImmer
Tired of spread operator madness? 🤪 Imagine writing state updates that LOOK like mutations but are actually 100% immutable under the hood. That's Immer! It's like having a magic notebook — scribble whatever you want, and it creates a clean copy automatically. No more ...spreading ...everything ...everywhere! 🎩✨
🟠 Impact: HIGH — Immer turns painful nested updates into simple, readable one-liners that just work
📋 In this section: useImmer Hook • Nested State Updates • Array Manipulation • Complex State Structures
Why this matters: Immer eliminates the mental overhead of tracking nested updates. You write code that looks like mutations, but Immer creates immutable updates automatically. Perfect for complex state structures.
import { useImmer } from 'use-immer';// ❌ WITHOUT IMMER: Verbose and error-pronefunction TodoListWithoutImmer() {const [todos, setTodos] = useState([{ id: 1, text: 'Learn React', completed: false, tags: ['learning'] }]);const toggleTodo = (id: number) => {setTodos(todos.map(todo =>todo.id === id? { ...todo, completed: !todo.completed }: todo));};const addTag = (todoId: number, tag: string) => {setTodos(todos.map(todo =>todo.id === todoId? { ...todo, tags: [...todo.tags, tag] } // Nested spread!: todo));};return (<div>{todos.map(todo => (<div key={todo.id}><inputtype="checkbox"checked={todo.completed}onChange={() => toggleTodo(todo.id)}/>{todo.text}</div>))}</div>);}// ✅ WITH IMMER: Clean and intuitivefunction TodoListWithImmer() {const [todos, setTodos] = useImmer([{ id: 1, text: 'Learn React', completed: false, tags: ['learning'] }]);const toggleTodo = (id: number) => {setTodos(draft => {const todo = draft.find(t => t.id === id);if (todo) {todo.completed = !todo.completed; // Looks like mutation, but it's safe!}});};const addTag = (todoId: number, tag: string) => {setTodos(draft => {const todo = draft.find(t => t.id === todoId);if (todo) {todo.tags.push(tag); // Direct push! No spread needed.}});};return (<div>{todos.map(todo => (<div key={todo.id}><inputtype="checkbox"checked={todo.completed}onChange={() => toggleTodo(todo.id)}/>{todo.text}</div>))}</div>);}// Advanced: Complex nested updatesfunction ComplexStateExample() {const [state, setState] = useImmer({user: {profile: {name: 'John',settings: {theme: 'dark',notifications: {email: true,push: false}}},posts: [{ id: 1, title: 'Post 1', comments: [] }]}});// Update deeply nested property - one line!const toggleEmailNotifications = () => {setState(draft => {draft.user.profile.settings.notifications.email =!draft.user.profile.settings.notifications.email;});};// Add comment to post - direct array manipulation!const addComment = (postId: number, comment: string) => {setState(draft => {const post = draft.user.posts.find(p => p.id === postId);if (post) {post.comments.push({ id: Date.now(), text: comment });}});};return (<div><button onClick={toggleEmailNotifications}>Toggle Email Notifications</button>{/* ... */}</div>);}
⚡ Unit 75: Cleaner Reducer with useImmerReducer
If useReducer is the serious, buttoned-up version of state management, then useImmerReducer is its cool, laid-back cousin. 😎 Write your reducer cases with direct mutations — no more return statements with spread operators everywhere. Your reducers just got 50% shorter and 200% more readable!
🔵 Impact: MEDIUM — Cleaner reducers mean fewer bugs in complex state logic — your team will thank you during code reviews
📋 In this section: useImmerReducer API • Reducer Simplification • Action Patterns • Todo App Example
Why this matters: When you have complex state logic that benefits from a reducer pattern, useImmerReducer makes it much more readable. Perfect for state machines, undo/redo, or complex form state.
import { useImmerReducer } from 'use-immer';// ❌ WITHOUT IMMER: Verbose reducertype State = {items: Array<{ id: number; name: string; completed: boolean }>;filter: 'all' | 'active' | 'completed';};type Action =| { type: 'ADD_ITEM'; payload: string }| { type: 'TOGGLE_ITEM'; payload: number }| { type: 'DELETE_ITEM'; payload: number }| { type: 'SET_FILTER'; payload: 'all' | 'active' | 'completed' };function reducerWithoutImmer(state: State, action: Action): State {switch (action.type) {case 'ADD_ITEM':return {...state,items: [...state.items, {id: Date.now(),name: action.payload,completed: false}]};case 'TOGGLE_ITEM':return {...state,items: state.items.map(item =>item.id === action.payload? { ...item, completed: !item.completed }: item)};case 'DELETE_ITEM':return {...state,items: state.items.filter(item => item.id !== action.payload)};case 'SET_FILTER':return { ...state, filter: action.payload };default:return state;}}// ✅ WITH IMMER: Clean and readablefunction reducerWithImmer(draft: State, action: Action) {switch (action.type) {case 'ADD_ITEM':draft.items.push({id: Date.now(),name: action.payload,completed: false});break;case 'TOGGLE_ITEM':const item = draft.items.find(i => i.id === action.payload);if (item) {item.completed = !item.completed;}break;case 'DELETE_ITEM':draft.items = draft.items.filter(i => i.id !== action.payload);break;case 'SET_FILTER':draft.filter = action.payload;break;}}function TodoApp() {const [state, dispatch] = useImmerReducer(reducerWithImmer, {items: [],filter: 'all'});const filteredItems = state.items.filter(item => {if (state.filter === 'active') return !item.completed;if (state.filter === 'completed') return item.completed;return true;});return (<div><inputonKeyDown={(e) => {if (e.key === 'Enter') {dispatch({ type: 'ADD_ITEM', payload: e.currentTarget.value });e.currentTarget.value = '';}}}placeholder="Add todo..."/><div><button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'all' })}>All</button><button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'active' })}>Active</button><button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'completed' })}>Completed</button></div><ul>{filteredItems.map(item => (<li key={item.id}><inputtype="checkbox"checked={item.completed}onChange={() => dispatch({ type: 'TOGGLE_ITEM', payload: item.id })}/>{item.name}<button onClick={() => dispatch({ type: 'DELETE_ITEM', payload: item.id })}>Delete</button></li>))}</ul></div>);}
🌐 Remote State: TanStack Query (React Query)
Here's a mind-blowing fact: ~80% of what you call 'state management' is actually just fetching data from a server! 🤯 TanStack Query swoops in like a superhero and handles caching, deduplication, refetching, and optimistic updates — all automatically. Say goodbye to isLoading useState nightmares!
🔴 Impact: CRITICAL — TanStack Query single-handedly eliminates 80% of your state management complexity — it's the biggest bang for your buck
📋 In this section: Query Keys • Caching Strategy • Mutations • Optimistic Updates • Request Deduplication
Key Benefit: You don't manage loading states, error states, or caching manually. The library handles it all, and multiple components can use the same data without duplicate requests.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';// Simple data fetching with automatic cachingfunction UserProfile({ userId }: { userId: string }) {const { data, isLoading, error } = useQuery({queryKey: ['user', userId],queryFn: async () => {const res = await fetch(`/api/users/${userId}`);if (!res.ok) throw new Error('Failed to fetch');return res.json();},staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes});if (isLoading) return <div>Loading...</div>;if (error) return <div>Error: {error.message}</div>;return <div>{data.name}</div>;}// Use the same query in another component - no duplicate request!function UserAvatar({ userId }: { userId: string }) {const { data } = useQuery({queryKey: ['user', userId], // Same key = same cachequeryFn: async () => {const res = await fetch(`/api/users/${userId}`);return res.json();},});return <img src={data?.avatar} alt={data?.name} />;}// Mutations with optimistic updatesfunction LikeButton({ postId }: { postId: string }) {const queryClient = useQueryClient();const mutation = useMutation({mutationFn: async () => {const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });return res.json();},onMutate: async () => {// Cancel outgoing refetchesawait queryClient.cancelQueries({ queryKey: ['post', postId] });// Snapshot previous valueconst previous = queryClient.getQueryData(['post', postId]);// Optimistically updatequeryClient.setQueryData(['post', postId], (old: any) => ({...old,likes: old.likes + 1,isLiked: true}));return { previous };},onError: (err, variables, context) => {// Rollback on errorqueryClient.setQueryData(['post', postId], context?.previous);},onSettled: () => {// Refetch to ensure syncqueryClient.invalidateQueries({ queryKey: ['post', postId] });},});return (<button onClick={() => mutation.mutate()}>Like</button>);}
🔗 URL State: nuqs (Next.js Query State)
Stop fighting with URL params manually! 🛑 nuqs gives you useState-like simplicity for URL query parameters. Type-safe, auto-synced, and works perfectly with Next.js App Router. Share a link and the state is already there — it's like teleportation for your app state! 🚀
🔵 Impact: MEDIUM — URL state done right means shareable, bookmarkable, and back-button-friendly user experiences
📋 In this section: Query Params • Type-Safe Parsers • Pagination • Complex Filters
Key Benefit: No more manual URL parsing or manual state syncing. The library handles all the edge cases automatically.
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';// Simple string query paramfunction SearchPage() {const [search, setSearch] = useQueryState('q', parseAsString);return (<div><inputvalue={search || ''}onChange={(e) => setSearch(e.target.value || null)}placeholder="Search..."/></div>);}// Integer with default valuefunction PaginatedList() {const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));return (<div><button onClick={() => setPage(page - 1)} disabled={page === 1}>Previous</button><span>Page {page}</span><button onClick={() => setPage(page + 1)}>Next</button></div>);}// Multiple query paramsfunction FilterableTable() {const [search, setSearch] = useQueryState('search', parseAsString);const [sortBy, setSortBy] = useQueryState('sort', parseAsString.withDefault('name'));const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));return (<div><inputvalue={search || ''}onChange={(e) => setSearch(e.target.value || null)}placeholder="Search..."/><select value={sortBy} onChange={(e) => setSortBy(e.target.value)}><option value="name">Name</option><option value="date">Date</option><option value="price">Price</option></select>{/* URL automatically updates: /table?search=test&sort=price&page=2 */}</div>);}// Custom parser for complex typesimport { parseAsJson } from 'nuqs';function ComplexState() {const [filters, setFilters] = useQueryState('filters',parseAsJson<{ category: string; tags: string[] }>());const updateFilters = (newFilters: { category: string; tags: string[] }) => {setFilters(newFilters);};return (<div>{/* Filters automatically synced with URL */}</div>);}
🎯 TL;DR: State Management Strategy for 2025
🟢 Impact: LOW — Quick cheat sheet to remember — pin this to your desk and never overthink state management again!
📋 In this section: Remote State Strategy • URL State Strategy • Local State Strategy • Shared State Strategy
1. Remote State (~80% of state): Use TanStack Query or SWR. They handle caching, deduplication, loading states, error states, optimistic updates, and more. Don't use Redux for this.
2. URL State (~10% of state): Use nuqs for query parameters. Don't manually sync state with URLs - it's error-prone.
3. Local State (~5% of state): Use useState or useReducer. For complex nested updates, use useImmer or useImmerReducer.
4. Shared State (~5% of state): Use Zustand when Context becomes unwieldy. It's simple, performant, and doesn't require providers. Only use Context for 1-2 shared concerns.
Result: ~90% of your state management problems disappear. You'll have less code, better performance, and easier maintenance. No need for Redux in most cases!