Managing state in JavaScript applications feels like conducting a complex orchestra. Each component needs access to the right data at the right time, and how we organize this flow can make or break an application’s maintainability. Over the years, I’ve worked with numerous approaches, each with its own strengths and trade-offs. The patterns I’ll share represent solutions to real problems I’ve encountered while building applications that need to scale.
Centralized stores create a single source of truth for application state. This approach separates state management from UI components, making both easier to reason about. When using libraries like Redux, you define how state changes through pure functions called reducers. This predictability becomes invaluable when debugging complex applications.
// Enhanced Redux setup with middleware
import { createStore, applyMiddleware } from 'redux';
import { thunk } from 'redux-thunk';
const initialState = {
loading: false,
data: null,
error: null
};
function apiReducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { loading: false, data: action.payload, error: null };
case 'FETCH_FAILURE':
return { loading: false, data: null, error: action.payload };
default:
return state;
}
}
// Async action creator using thunk middleware
const fetchUserData = (userId) => async (dispatch) => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_FAILURE', payload: error.message });
}
};
const store = createStore(apiReducer, applyMiddleware(thunk));
The Context API offers a built-in solution for React developers. I often use it for medium-sized applications where installing additional libraries seems unnecessary. Combined with useReducer, it provides similar capabilities to Redux but with less setup overhead.
// Advanced Context pattern with memoization
import React, { createContext, useReducer, useContext, useMemo } from 'react';
const AuthContext = createContext();
function authReducer(state, action) {
switch (action.type) {
case 'LOGIN':
return {
...state,
user: action.payload,
isAuthenticated: true
};
case 'LOGOUT':
return {
user: null,
isAuthenticated: false
};
case 'UPDATE_PROFILE':
return {
...state,
user: { ...state.user, ...action.payload }
};
default:
return state;
}
}
function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isAuthenticated: false
});
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({
...state,
login: (userData) => dispatch({ type: 'LOGIN', payload: userData }),
logout: () => dispatch({ type: 'LOGOUT' }),
updateProfile: (updates) => dispatch({ type: 'UPDATE_PROFILE', payload: updates })
}), [state]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
// Custom hook with validation
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Atomic state management changed how I think about component updates. Instead of storing everything in large objects, this pattern encourages breaking state into tiny pieces. Components only subscribe to what they need, which significantly reduces unnecessary re-renders.
// Complex atomic state with derived values
import { atom, useAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
const productsAtom = atom([]);
const filterAtom = atom('');
// Derived atom for filtered products
const filteredProductsAtom = atom((get) => {
const products = get(productsAtom);
const filter = get(filterAtom);
return products.filter(product =>
product.name.toLowerCase().includes(filter.toLowerCase())
);
});
// Derived atom for statistics
const productsStatsAtom = atom((get) => {
const products = get(productsAtom);
return {
total: products.length,
categories: [...new Set(products.map(p => p.category))],
averagePrice: products.reduce((sum, p) => sum + p.price, 0) / products.length
};
});
function ProductList() {
const [filter, setFilter] = useAtom(filterAtom);
const [filteredProducts] = useAtom(filteredProductsAtom);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter products..."
/>
{filteredProducts.map(product => (
<ProductItem key={product.id} product={product} />
))}
</div>
);
}
Finite state machines bring mathematical precision to state management. I’ve found them particularly useful for complex workflows where certain states should only be reachable through specific actions. This approach eliminates entire categories of bugs related to invalid state transitions.
// Complex state machine for payment processing
import { createMachine, assign, interpret } from 'xstate';
const paymentMachine = createMachine({
id: 'payment',
initial: 'idle',
context: {
amount: 0,
paymentMethod: null,
error: null
},
states: {
idle: {
on: {
START: 'selectingMethod'
}
},
selectingMethod: {
on: {
SELECT_METHOD: {
target: 'enteringDetails',
actions: assign({
paymentMethod: (_, event) => event.method
})
},
CANCEL: 'idle'
}
},
enteringDetails: {
on: {
SUBMIT_DETAILS: 'processing',
CANCEL: 'selectingMethod'
}
},
processing: {
invoke: {
src: 'processPayment',
onDone: 'success',
onError: {
target: 'error',
actions: assign({
error: (_, event) => event.data
})
}
}
},
success: {
type: 'final'
},
error: {
on: {
RETRY: 'processing',
CANCEL: 'idle'
}
}
}
});
// Implementation with services
const paymentService = interpret(
paymentMachine.withConfig({
services: {
processPayment: async (context) => {
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({
amount: context.amount,
method: context.paymentMethod
})
});
if (!response.ok) throw new Error('Payment failed');
return response.json();
}
}
})
);
Reactive programming with observables handles asynchronous operations beautifully. When dealing with multiple event sources that need coordination, RxJS provides operators that make complex data flows manageable and readable.
// Complex observable pattern for real-time dashboard
import { fromEvent, combineLatest, interval } from 'rxjs';
import { map, filter, switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators';
// DOM elements
const searchInput = document.getElementById('search');
const filterSelect = document.getElementById('filter');
const refreshButton = document.getElementById('refresh');
// Create observables from user interactions
const search$ = fromEvent(searchInput, 'input').pipe(
map(event => event.target.value),
debounceTime(400),
distinctUntilChanged()
);
const filter$ = fromEvent(filterSelect, 'change').pipe(
map(event => event.target.value)
);
const refresh$ = fromEvent(refreshButton, 'click');
// Auto-refresh every 30 seconds
const autoRefresh$ = interval(30000);
// Combine all streams
const dashboard$ = combineLatest([search$, filter$]).pipe(
switchMap(([searchTerm, filterValue]) =>
combineLatest([refresh$, autoRefresh$]).pipe(
switchMap(() =>
fetchData(searchTerm, filterValue)
)
)
)
);
// Subscribe to updates
dashboard$.subscribe(data => {
updateDashboard(data);
});
async function fetchData(search, filter) {
const params = new URLSearchParams({ search, filter });
const response = await fetch(`/api/data?${params}`);
return response.json();
}
Proxy-based state management offers a different approach that feels more intuitive in many cases. The automatic dependency tracking means I spend less time optimizing re-renders and more time building features.
// Advanced MobX pattern with computed values
import { makeObservable, observable, action, computed, runInAction } from 'mobx';
class ShoppingCart {
items = [];
constructor() {
makeObservable(this, {
items: observable,
addItem: action,
removeItem: action,
total: computed,
itemCount: computed
});
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(item => item.product.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ product, quantity });
}
}
removeItem(productId) {
this.items = this.items.filter(item => item.product.id !== productId);
}
get total() {
return this.items.reduce((sum, item) =>
sum + (item.product.price * item.quantity), 0
);
}
get itemCount() {
return this.items.reduce((count, item) => count + item.quantity, 0);
}
// Async action pattern
async loadCart() {
try {
const response = await fetch('/api/cart');
const items = await response.json();
runInAction(() => {
this.items = items;
});
} catch (error) {
console.error('Failed to load cart:', error);
}
}
}
// React integration with observer
import { observer } from 'mobx-react-lite';
const CartDisplay = observer(({ cart }) => (
<div>
<h2>Cart ({cart.itemCount} items)</h2>
<p>Total: ${cart.total.toFixed(2)}</p>
{cart.items.map(item => (
<CartItem key={item.product.id} item={item} />
))}
</div>
));
Each pattern serves different needs. Centralized stores work well for large applications with complex state transitions. Context API fits medium-sized React applications where you want to avoid external dependencies. Atomic state excels when performance is critical and you need fine-grained control over re-renders.
Finite state machines are perfect for workflows with strict rules about state changes. Reactive programming handles complex asynchronous operations beautifully. Proxy-based state offers a more intuitive developer experience for many use cases.
The choice ultimately depends on your team’s experience, application requirements, and personal preference. I often mix patterns within a single application, using each where it makes the most sense. The key is understanding the trade-offs and choosing intentionally rather than following trends.
State management continues to evolve, and new patterns emerge regularly. What remains constant is the need for clear data flow, predictable behavior, and maintainable code. These patterns provide proven approaches to achieving these goals in JavaScript applications of all sizes.