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 is how we handle data in web applications. It's like keeping track of everything that happens in an app, from user inputs to data from servers. I remember when I first built a simple app, I stored data in variables scattered around. It worked for a small project, but as the app grew, it became messy and hard to debug. That's when I learned about structured state management patterns. These methods help keep data organized, predictable, and efficient, especially as apps get bigger and more complex. In this article, I'll share seven modern patterns that I've used to build scalable web applications. I'll explain each one simply, with code examples, so you can understand how to apply them.
Centralized stores are a common way to manage state. They create a single place where all your app's data lives. Think of it as a central hub that every part of your app can access. I've used Redux for this in many projects. It makes state changes predictable because you have to follow specific steps to update anything. For instance, you dispatch actions to describe what happened, and reducers handle how the state should change. This is great for teams because everyone knows how data flows. Here's a basic example of a Redux slice for managing user data. It sets up an initial state and defines how to update it when actions are dispatched.
// Redux slice for user management
import { createSlice } from '@reduxjs/toolkit';
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false },
reducers: {
setUser: (state, action) => {
state.data = action.payload;
},
setLoading: (state, action) => {
state.loading = action.payload;
}
}
});
export const { setUser, setLoading } = userSlice.actions;
export default userSlice.reducer;
In one of my apps, I used this to handle user login. When a user logs in, I dispatch the setUser action with their data. If something goes wrong, I can easily trace back through the actions to find the issue. This pattern prevents random parts of the app from changing state directly, which reduces bugs. It's especially useful for large applications where multiple developers are working on different features. The unidirectional data flow means you always know where state changes come from.
Context providers are another pattern I rely on for sharing state across components without passing props down manually. If you've ever dealt with "prop drilling," where you pass data through many layers of components, you'll appreciate this. React's Context API lets you create a context that can be accessed by any component in the tree. I often use this for themes, like switching between light and dark modes. It's lightweight and doesn't require extra libraries for simpler cases.
// Theme context implementation
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
In a recent project, I used this to manage app settings. Any component could use the useTheme hook to get or change the theme. It made the code cleaner because I didn't have to pass theme props through every level. However, I learned that context is best for state that doesn't change often. If it updates frequently, it can cause performance issues because all consuming components re-render. For dynamic data, I combine it with other patterns.
Atomic state breaks down application state into tiny, independent pieces. Instead of one big state object, you have many small atoms that can be composed together. I've used Jotai for this, and it's fantastic for optimizing re-renders. Each atom is a unit of state, and components only update when the atoms they use change. This is like having individual switches for different parts of your app, rather than one master switch.
// Jotai atom for cart state
import { atom } from 'jotai';
export const cartAtom = atom([]);
export const cartTotalAtom = atom((get) =>
get(cartAtom).reduce((sum, item) => sum + item.price, 0)
);
I implemented this in an e-commerce app for the shopping cart. The cartAtom held the list of items, and cartTotalAtom calculated the total price dynamically. When I added an item to the cart, only components using cartAtom or cartTotalAtom re-rendered. This made the app faster because unrelated components didn't update. It's a more granular approach that fits well with React's component model. I found it easier to reason about than a monolithic store for certain types of state.
Server state synchronization is crucial for apps that fetch data from APIs. Tools like TanStack Query (formerly React Query) handle this beautifully. They manage loading states, errors, caching, and background updates automatically. I used to write a lot of boilerplate code for fetching data, but this pattern simplifies it. It ensures that your UI stays in sync with the server without manual intervention.
// React Query for data fetching
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
});
if (isLoading) return <div>Loading user data...</div>;
if (error) return <div>Error loading user: {error.message}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
In a social media app I worked on, this pattern reduced the code for data fetching by half. It automatically cached user profiles, so if multiple components needed the same data, it was fetched only once. Background refetching kept the data fresh without the user noticing. I also used it for pagination and infinite scrolling. The built-in retry logic handled network issues gracefully. It's one of those tools that once you start using, you wonder how you managed without it.
Local component state is for data that only matters within a single component or a small part of the UI. I use React's useState hook for this all the time. It's perfect for form inputs, toggles, or any temporary state that doesn't need to be shared globally. Keeping state local makes components more reusable and easier to test.
// Local state with useState for a search component
import React, { useState } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearch = async () => {
if (!query.trim()) return;
setIsSearching(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsSearching(false);
}
};
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter search term"
/>
<button onClick={handleSearch} disabled={isSearching}>
{isSearching ? 'Searching...' : 'Search'}
</button>
<ul>
{results.map((result, index) => (
<li key={index}>{result.title}</li>
))}
</ul>
</div>
);
}
I built a dashboard where each widget had its own local state for things like filters or expanded views. This kept the global state clean and focused on shared data. One thing I learned is to avoid lifting state up too early. If data is only used in one place, start with local state. You can always move it to a global store later if needed. This approach follows the principle of least privilege, giving components only the state they need.
State machines model application behavior as a set of states and transitions. I use XState for complex workflows like wizards, authentication, or any process with clear steps. It makes the code more declarative and less error-prone. By defining all possible states upfront, you avoid invalid states, like showing a success message while still loading.
// XState authentication machine
import { createMachine } from 'xstate';
const authMachine = createMachine({
id: 'auth',
initial: 'idle',
states: {
idle: {
on: {
LOGIN: 'loading'
}
},
loading: {
on: {
SUCCESS: 'authenticated',
FAILURE: 'error'
}
},
authenticated: {
on: {
LOGOUT: 'idle'
}
},
error: {
on: {
RETRY: 'loading'
}
}
}
});
// Example usage in a component
import { useMachine } from '@xstate/react';
function LoginForm() {
const [state, send] = useMachine(authMachine);
const handleLogin = async (credentials) => {
send('LOGIN');
try {
await loginAPI(credentials);
send('SUCCESS');
} catch (error) {
send('FAILURE');
}
};
return (
<div>
{state.matches('idle') && <button onClick={() => handleLogin()}>Log In</button>}
{state.matches('loading') && <div>Logging in...</div>}
{state.matches('authenticated') && <div>Welcome!</div>}
{state.matches('error') && (
<div>
Login failed. <button onClick={() => send('RETRY')}>Try Again</button>
</div>
)}
</div>
);
}
In a payment processing app, I used a state machine for the checkout flow. It had states like cart, shipping, payment, and confirmation. Transitions between states were clear, and I could easily add side effects, like validating addresses before moving to payment. Testing was straightforward because I could simulate each state. This pattern is excellent for processes where the order of operations matters.
Optimistic updates make interfaces feel faster by updating the UI immediately, assuming an action will succeed. If it fails, you roll back the change. I use this for actions like liking a post or adding items to a cart. It improves user experience because they don't have to wait for server responses to see changes.
// Optimistic update with rollback for a like button
import React, { useState } from 'react';
function LikeButton({ postId }) {
const [likes, setLikes] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [error, setError] = useState(null);
const handleLike = async () => {
const previousLikes = likes;
const previousIsLiked = isLiked;
// Optimistically update UI
setLikes(prev => prev + 1);
setIsLiked(true);
setError(null);
try {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
// Success - no need to do anything, UI is already updated
} catch (err) {
// Rollback on failure
setLikes(previousLikes);
setIsLiked(previousIsLiked);
setError('Failed to like post. Please try again.');
}
};
return (
<div>
<button onClick={handleLike} disabled={isLiked}>
{isLiked ? 'Liked' : 'Like'} ({likes})
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
I added this to a messaging app for sending messages. When a user hits send, the message appears immediately in the chat. If the send fails, it shows an error and reverts the UI. Users appreciated the responsiveness. The key is to handle rollbacks properly, so the UI always matches the server state eventually. I also use this with undo functionality, where users can cancel an action if it hasn't confirmed yet.
Combining these patterns has helped me build robust web applications. For example, in a project management tool, I used centralized stores for user and project data, context for UI themes, atomic state for individual task states, server state synchronization for real-time updates, local state for modal dialogs, state machines for task workflows, and optimistic updates for quick actions like archiving tasks. Each pattern addressed a specific need, and together they created a cohesive system.
When I started, I made the mistake of using one pattern for everything. Now, I mix and match based on the situation. For instance, I might use local state for a dropdown's open/close state, but a centralized store for user authentication. The goal is to balance simplicity with scalability. As apps grow, having a clear state management strategy saves time and reduces bugs.
I encourage you to experiment with these patterns in your projects. Start with the basics, like local state and context, then gradually incorporate others as needed. Remember, the best pattern depends on your app's requirements. Don't overcomplicate things early on. Keep learning and adapting, and you'll find what works for you. State management doesn't have to be daunting—it's just a way to keep your app's data organized and predictable.
📘 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)