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!