As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
State management has always been at the heart of my work building interactive web applications. Over the years, I've watched projects grow from simple scripts to complex systems where data flow determines success or failure. The right state management approach can turn a tangled mess into a clean, maintainable codebase. In this article, I'll share seven modern patterns that have helped me scale applications efficiently, complete with code examples and insights from my experiences.
Global state containers provide a single source of truth for application-wide data. I often turn to libraries like Redux when working on large teams where predictability is key. The unidirectional data flow forces discipline, making it easier to trace how state changes over time. Actions describe events, and reducers handle transitions, creating a clear audit trail. This pattern excels in scenarios requiring debugging tools like time-travel or integrating middleware for logging or analytics.
// Enhanced Redux example with async actions
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUserData = createAsyncThunk(
'user/fetchData',
async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null },
reducers: {
clearError: (state) => {
state.error = null;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUserData.pending, (state) => {
state.loading = true;
})
.addCase(fetchUserData.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUserData.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
export const { clearError } = userSlice.actions;
export default userSlice.reducer;
In one project, using Redux helped our team coordinate across features without stepping on each other's toes. We could see exactly how state evolved, which was invaluable during code reviews.
Component-level state handles data that doesn't need to be shared globally. I rely on React's useState hook for UI-specific conditions like form inputs or modal states. It keeps components self-contained and easier to test. However, I've learned to avoid overusing it for data that might be needed elsewhere, as it can lead to prop drilling.
// Advanced local state with custom hook
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
function SettingsPanel() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [notifications, setNotifications] = useLocalStorage('notifications', true);
return (
<div className={theme}>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Switch Theme
</button>
<label>
<input
type="checkbox"
checked={notifications}
onChange={(e) => setNotifications(e.target.checked)}
/>
Enable Notifications
</label>
</div>
);
}
I once built a settings page where users could customize their experience. Using local state with persistence to localStorage made it feel snappy and responsive.
Server state synchronization separates remote data from local state, reducing boilerplate. Tools like React Query handle caching, background updates, and error states automatically. I've found this especially useful in data-heavy applications where keeping UI in sync with the server is critical.
// React Query with mutations and optimistic updates
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(res => res.json())
});
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo)
}),
onMutate: async (newTodo) => {
await queryClient.cancelQueries(['todos']);
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], old => [...old, { id: Date.now(), ...newTodo }]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries(['todos']);
}
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
{todos.map(todo => (
<div key={todo.id}>{todo.text}</div>
))}
<button onClick={() => mutation.mutate({ text: 'New Todo' })}>
Add Todo
</button>
</div>
);
}
In an e-commerce app, React Query eliminated countless loading spinners and retry buttons. The background refetching kept product listings fresh without user intervention.
Atomic state libraries optimize performance by updating only components that depend on specific state slices. I've used Zustand and Jotai in projects with deep component trees where re-renders were causing lag. They provide a lightweight alternative to heavier solutions.
// Zustand store with middleware and persistence
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useCartStore = create(
persist(
(set, get) => ({
items: [],
addItem: (product) => set((state) => ({
items: [...state.items, { ...product, quantity: 1 }]
})),
removeItem: (productId) => set((state) => ({
items: state.items.filter(item => item.id !== productId)
})),
getTotal: () => get().items.reduce((sum, item) => sum + item.price, 0)
}),
{
name: 'cart-storage'
}
)
);
// Component using the store
function CartSummary() {
const items = useCartStore(state => state.items);
const total = useCartStore(state => state.getTotal());
return (
<div>
<h3>Cart ({items.length} items)</h3>
<p>Total: ${total}</p>
</div>
);
}
I implemented a shopping cart that persisted across page refreshes. Zustand made it simple to manage the cart state without unnecessary re-renders in other parts of the app.
Immutable state updates prevent subtle bugs caused by accidental mutations. Libraries like Immer allow writing code that looks mutable but generates new objects under the hood. This has saved me hours of debugging in complex state transformations.
// Immer with nested state updates
import produce from 'immer';
const initialState = {
users: [
{ id: 1, profile: { name: 'Alice', settings: { theme: 'light' } } },
{ id: 2, profile: { name: 'Bob', settings: { theme: 'dark' } } }
],
lastUpdated: null
};
function updateUserTheme(state, userId, newTheme) {
return produce(state, draft => {
const user = draft.users.find(u => u.id === userId);
if (user) {
user.profile.settings.theme = newTheme;
draft.lastUpdated = new Date().toISOString();
}
});
}
// Usage
const newState = updateUserTheme(initialState, 1, 'dark');
console.log(newState.users[0].profile.settings.theme); // 'dark'
console.log(initialState.users[0].profile.settings.theme); // 'light' (unchanged)
In a collaborative editing feature, Immer ensured that concurrent changes didn't corrupt the shared state. The code remained readable while maintaining immutability.
State machines model application behavior as a set of finite states and transitions. I've used XState to handle complex workflows like multi-step forms or authentication processes. It makes edge cases explicit and the logic easier to test.
// XState machine for a checkout process
import { createMachine, assign } from 'xstate';
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
context: {
items: [],
shippingAddress: null,
paymentMethod: null
},
states: {
cart: {
on: {
ADD_ITEM: {
actions: assign({
items: (context, event) => [...context.items, event.item]
})
},
PROCEED_TO_SHIPPING: 'shipping'
}
},
shipping: {
on: {
SET_ADDRESS: {
actions: assign({
shippingAddress: (_, event) => event.address
})
},
BACK: 'cart',
NEXT: 'payment'
}
},
payment: {
on: {
SET_PAYMENT: {
actions: assign({
paymentMethod: (_, event) => event.method
})
},
BACK: 'shipping',
SUBMIT: 'processing'
}
},
processing: {
invoke: {
src: 'processOrder',
onDone: 'success',
onError: 'failure'
}
},
success: { type: 'final' },
failure: {
on: {
RETRY: 'payment'
}
}
}
});
// Using the machine
import { useMachine } from '@xstate/react';
function CheckoutFlow() {
const [state, send] = useMachine(checkoutMachine);
return (
<div>
{state.matches('cart') && (
<CartView
items={state.context.items}
onAddItem={(item) => send({ type: 'ADD_ITEM', item })}
onProceed={() => send('PROCEED_TO_SHIPPING')}
/>
)}
{state.matches('shipping') && (
<ShippingView
address={state.context.shippingAddress}
onSetAddress={(address) => send({ type: 'SET_ADDRESS', address })}
onBack={() => send('BACK')}
onNext={() => send('NEXT')}
/>
)}
{/* Other state views */}
</div>
);
}
I built a ticket booking system where the state machine clearly defined each step from selection to confirmation. It handled errors and retries gracefully, improving the user experience.
Reactive state primitives offer fine-grained control over updates. Solid.js signals track dependencies and update the DOM directly, avoiding virtual DOM overhead. I've experimented with this in performance-critical applications.
// Solid.js with derived state and effects
import { createSignal, createEffect, createMemo } from 'solid-js';
function ShoppingCart() {
const [items, setItems] = createSignal([]);
const [taxRate, setTaxRate] = createSignal(0.08);
const subtotal = createMemo(() =>
items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const tax = createMemo(() => subtotal() * taxRate());
const total = createMemo(() => subtotal() + tax());
createEffect(() => {
console.log(`Cart updated: ${items().length} items`);
});
function addItem(product) {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
}
return (
<div>
<h2>Cart Total: ${total().toFixed(2)}</h2>
<button onClick={() => addItem({ id: 1, name: 'Widget', price: 10 })}>
Add Widget
</button>
<button onClick={() => setTaxRate(0.1)}>
Set Tax to 10%
</button>
</div>
);
}
In a real-time dashboard, Solid.js signals reduced rendering times significantly. The direct DOM updates made the interface feel instantaneous.
Choosing the right state management pattern depends on your application's needs. I consider factors like team size, data volatility, and performance requirements. For small projects, component state might suffice. As complexity grows, global stores or atomic libraries become necessary. Server state tools handle external data, while state machines manage workflows. Reactive primitives offer performance gains, and immutable updates ensure reliability.
I often mix patterns within a single application. A global store for user preferences, local state for UI interactions, and server state for fetched data. This hybrid approach balances simplicity with power.
State management continues to evolve. New libraries and patterns emerge, but the core principles remain. Understanding these options helps me build applications that scale smoothly and maintain developer happiness.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)