Let's talk about state management in React - something that can get quite interesting as our applications grow. I've spent time working with e-commerce platforms, and I'd love to share some insights about managing state effectively. Don't worry, we'll use simple examples to understand complex concepts!
Note: The code examples in this article are simplified for illustration purposes. They're meant to demonstrate concepts rather than being production-ready solutions.
Imagine you're building an e-commerce site where users can add items to their cart from different pages - the product listing, quick view modals, and product detail pages. One common challenge is keeping the cart state synchronized across all these components.
Here's an example of how we might initially approach this (remember, this is a simplified example):
// Example: Not the ideal way to handle cart state
function ProductCard() {
const [cartCount, setCartCount] = useState(0);
const { globalCart } = useGlobalStore();
const { headerCart } = useHeaderState();
// 🤔 This can get messy quickly
useEffect(() => {
if (globalCart.count !== cartCount) {
setCartCount(globalCart.count);
}
// What about headerCart?
}, [globalCart]);
}
Instead, let's look at a cleaner approach using React Query:
// A better way to handle cart state
function useCartState() {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['cart'],
queryFn: fetchCartData,
// Keep cart data fresh
staleTime: 1000 * 60, // 1 minute
// Update cart across components
onSuccess: (data) => {
queryClient.setQueryData(['cart'], data);
},
});
}
Let's look at a product listing page where performance really matters. Here's a simple example to illustrate common performance challenges:
// Example: Performance considerations
function ProductGrid() {
const [products, setProducts] = useState({});
// This might cause unnecessary re-renders
return (
<div className='grid'>
{Object.entries(products).map(([id, product]) => (
<ProductCard
key={id}
{...product} // Spreading all product data
onAddToCart={() => handleAddToCart(product)}
/>
))}
</div>
);
}
Here's how we can make it better:
// A more optimized approach
function ProductGrid() {
const { data: products } = useProductsQuery();
return (
<div className='grid'>
{Object.entries(products || {}).map(([id]) => (
<MemoizedProductCard
key={id}
productId={id} // Pass only what's needed
/>
))}
</div>
);
}
// Each product card manages its own data
const MemoizedProductCard = memo(function ProductCard({ productId }) {
const { data: product } = useProductQuery(productId);
const { addToCart } = useCart();
if (!product) return null;
return (
<div className='card'>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<button onClick={() => addToCart(productId)}>Add to Cart</button>
</div>
);
});
Here's a simple example of managing a shopping cart with a custom hook:
// Example: Shopping cart hook
function useShoppingCart() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const addItem = useCallback(async (product) => {
try {
setLoading(true);
// This is where you'd normally call your API
const updatedCart = await addToCartApi(product);
setItems(updatedCart.items);
return updatedCart;
} catch (err) {
setError('Could not add item to cart');
console.error('Cart error:', err);
} finally {
setLoading(false);
}
}, []);
return {
items,
loading,
error,
addItem,
itemCount: items.length,
total: items.reduce((sum, item) => sum + item.price, 0),
};
}
Here's an example of managing a checkout process (simplified for illustration):
// Example: Checkout flow state machine
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
context: {
items: [],
shippingAddress: null,
paymentMethod: null,
},
states: {
cart: {
on: {
CHECKOUT: {
target: 'shipping',
guard: 'hasItems',
},
},
},
shipping: {
on: {
CONTINUE: {
target: 'payment',
actions: 'saveShipping',
},
BACK: 'cart',
},
},
payment: {
on: {
SUBMIT: {
target: 'confirming',
actions: 'savePayment',
},
BACK: 'shipping',
},
},
confirming: {
invoke: {
src: 'submitOrder',
onDone: 'success',
onError: 'payment',
},
},
success: {
type: 'final',
},
},
});
Here's how we might structure state in an e-commerce application (simplified for demonstration):
// Example: E-commerce state management
function useStoreState() {
// Products state
const products = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// Cache products for better performance
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Cart state with optimistic updates
const cart = useQuery({
queryKey: ['cart'],
queryFn: fetchCart,
// Optimistic updates for better UX
onMutate: async (newItem) => {
await queryClient.cancelQueries(['cart']);
const previous = queryClient.getQueryData(['cart']);
// Optimistically update cart
queryClient.setQueryData(['cart'], (old) => ({
...old,
items: [...old.items, newItem],
}));
return { previous };
},
});
return {
products,
cart,
isLoading: products.isLoading || cart.isLoading,
};
}
Here's a friendly guide to choosing state management solutions:
State management doesn't have to be overwhelming. Start simple, add complexity only when needed, and always think about the maintainability of your code. Remember, the goal is to build applications that are both enjoyable to use and maintain.
Have questions about any of these patterns? Feel free to reach out - I'd love to hear about your experiences with state management!