Advanced State Management Patterns for Production Apps
Deep dive into advanced state management patterns, context optimization, and avoiding common pitfalls in production React applications.
Introduction
State management is one of the most critical aspects of building production-ready React applications. While basic patterns like useState and useContext are great for simple apps, production applications require more sophisticated approaches to handle complex state, optimize performance, and maintain code quality at scale.
In this member-exclusive guide, we'll explore advanced patterns that we use at Cuppa to build performant, maintainable applications. You'll learn about context optimization, avoiding unnecessary re-renders, and implementing robust state management architectures.
The Performance Problem with Context
Let's start by addressing the elephant in the room: React Context can cause significant performance issues when used incorrectly. Here's a common antipattern:
// ❌ ANTIPATTERN: Single massive context
const AppContext = createContext({
user: null,
theme: 'light',
notifications: [],
preferences: {},
// ... 50 more fields
})
function App() {
const [state, setState] = useState({
user: null,
theme: 'light',
notifications: [],
preferences: {},
// ... all state in one object
})
return (
<AppContext.Provider value={state}>
<YourApp />
</AppContext.Provider>
)
}
Why this is bad: Every time ANY part of the state updates, EVERY component that consumes this context re-renders, even if they only use one field.
Pattern 1: Split Contexts by Domain
The first rule of advanced state management: separate contexts by concern.
// ✅ GOOD: Separate contexts
const UserContext = createContext(null)
const ThemeContext = createContext('light')
const NotificationContext = createContext([])
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [notifications, setNotifications] = useState([])
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
<YourApp />
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
)
}
Now components only re-render when the specific context they use changes.
Pattern 2: Context Selectors for Granular Updates
For complex state that you can't split, implement selectors:
import { createContext, useContext, useMemo, useRef, useSyncExternalStore } from 'react'
function createSelectorContext<State>() {
const Context = createContext<State | null>(null)
function Provider({ value, children }: { value: State; children: React.ReactNode }) {
const stateRef = useRef(value)
const listenersRef = useRef(new Set<() => void>())
// Update ref when value changes
useMemo(() => {
stateRef.current = value
listenersRef.current.forEach(listener => listener())
}, [value])
const subscribe = useMemo(
() => (listener: () => void) => {
listenersRef.current.add(listener)
return () => listenersRef.current.delete(listener)
},
[]
)
const getSnapshot = useMemo(() => () => stateRef.current, [])
return (
<Context.Provider value={value}>
{children}
</Context.Provider>
)
}
function useSelector<Selected>(selector: (state: State) => Selected): Selected {
const value = useContext(Context)
if (!value) throw new Error('useSelector must be used within Provider')
return useMemo(() => selector(value), [value, selector])
}
return { Provider, useSelector }
}
// Usage
interface AppState {
user: User | null
theme: Theme
notifications: Notification[]
preferences: Preferences
}
const { Provider: AppProvider, useSelector: useAppSelector } = createSelectorContext<AppState>()
// In components - only re-render when selected value changes
function UserProfile() {
const user = useAppSelector(state => state.user) // ✅ Only re-renders when user changes
return <div>{user?.name}</div>
}
function ThemeSwitcher() {
const theme = useAppSelector(state => state.theme) // ✅ Only re-renders when theme changes
return <button>{theme}</button>
}
Pattern 3: Atomic State with Zustand
For truly scalable state management, consider using Zustand with atomic state updates:
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface AppState {
user: User | null
setUser: (user: User | null) => void
theme: Theme
setTheme: (theme: Theme) => void
notifications: Notification[]
addNotification: (notification: Notification) => void
removeNotification: (id: string) => void
// Computed values
unreadCount: number
}
const useStore = create<AppState>()(
immer((set, get) => ({
user: null,
setUser: (user) => set({ user }),
theme: 'light',
setTheme: (theme) => set({ theme }),
notifications: [],
addNotification: (notification) =>
set((state) => {
state.notifications.push(notification)
}),
removeNotification: (id) =>
set((state) => {
state.notifications = state.notifications.filter(n => n.id !== id)
}),
get unreadCount() {
return get().notifications.filter(n => !n.read).length
},
}))
)
// Usage - only re-renders when the specific slice changes
function UserProfile() {
const user = useStore(state => state.user) // ✅ Granular subscription
return <div>{user?.name}</div>
}
function NotificationBadge() {
const unreadCount = useStore(state => state.unreadCount) // ✅ Only re-renders when count changes
return <span>{unreadCount}</span>
}
Pattern 4: Optimistic Updates with Server State
When working with server data, implement optimistic updates:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useLikePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (postId: string) => api.likePost(postId),
// Optimistic update
onMutate: async (postId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['post', postId] })
// Snapshot previous value
const previousPost = queryClient.getQueryData(['post', postId])
// Optimistically update
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
liked: true,
likeCount: old.likeCount + 1,
}))
// Return context for rollback
return { previousPost }
},
// Rollback on error
onError: (err, postId, context) => {
queryClient.setQueryData(['post', postId], context?.previousPost)
},
// Refetch on success
onSettled: (postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] })
},
})
}
// Usage
function LikeButton({ postId }: { postId: string }) {
const likePost = useLikePost()
return (
<button
onClick={() => likePost.mutate(postId)}
disabled={likePost.isPending}
>
{likePost.isPending ? 'Liking...' : 'Like'}
</button>
)
}
Pattern 5: Form State with Server Validation
Handle complex forms with client + server validation:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
username: z.string().min(3).max(20),
password: z.string().min(8),
})
type FormData = z.infer<typeof schema>
function SignupForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = async (data: FormData) => {
try {
await api.signup(data)
} catch (error) {
// Server validation errors
if (error.code === 'USERNAME_TAKEN') {
setError('username', {
type: 'server',
message: 'Username is already taken',
})
}
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email')}
type="email"
aria-invalid={errors.email ? 'true' : 'false'}
/>
{errors.email && <span>{errors.email.message}</span>}
<input
{...register('username')}
aria-invalid={errors.username ? 'true' : 'false'}
/>
{errors.username && <span>{errors.username.message}</span>}
<input
{...register('password')}
type="password"
aria-invalid={errors.password ? 'true' : 'false'}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing up...' : 'Sign up'}
</button>
</form>
)
}
Real-World Example: Building a Chat Application
Let's combine all patterns into a real chat app:
// Store setup
const useChatStore = create<ChatState>()(
immer((set, get) => ({
rooms: [],
messages: {},
typingUsers: {},
addMessage: (roomId, message) =>
set((state) => {
if (!state.messages[roomId]) {
state.messages[roomId] = []
}
state.messages[roomId].push(message)
}),
setTyping: (roomId, userId, isTyping) =>
set((state) => {
if (!state.typingUsers[roomId]) {
state.typingUsers[roomId] = new Set()
}
if (isTyping) {
state.typingUsers[roomId].add(userId)
} else {
state.typingUsers[roomId].delete(userId)
}
}),
}))
)
// Component
function ChatRoom({ roomId }: { roomId: string }) {
// Granular subscriptions
const messages = useChatStore(state => state.messages[roomId] ?? [])
const typingUsers = useChatStore(state => state.typingUsers[roomId] ?? new Set())
const addMessage = useChatStore(state => state.addMessage)
// Optimistic sending
const sendMessage = useMutation({
mutationFn: (text: string) => api.sendMessage(roomId, text),
onMutate: async (text) => {
const optimisticMessage = {
id: crypto.randomUUID(),
text,
userId: 'me',
timestamp: new Date(),
optimistic: true,
}
addMessage(roomId, optimisticMessage)
return { optimisticMessage }
},
onSuccess: (data, variables, context) => {
// Replace optimistic message with real one
const store = useChatStore.getState()
const messages = store.messages[roomId]
const index = messages.findIndex(m => m.id === context.optimisticMessage.id)
if (index !== -1) {
messages[index] = data
}
},
})
return (
<div>
{messages.map(msg => (
<Message
key={msg.id}
message={msg}
isOptimistic={msg.optimistic}
/>
))}
{typingUsers.size > 0 && (
<TypingIndicator users={Array.from(typingUsers)} />
)}
<MessageInput onSend={sendMessage.mutate} />
</div>
)
}
Performance Monitoring
Don't forget to measure! Use React DevTools Profiler:
import { Profiler } from 'react'
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) {
console.log({
id,
phase,
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime,
commitTime,
})
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<YourApp />
</Profiler>
)
}
Key Takeaways
- Split contexts by domain - Don't put everything in one context
- Use selectors - Only subscribe to what you need
- Consider Zustand - For complex state, use a proper state library
- Optimistic updates - Make your app feel instant
- Measure performance - Use React DevTools Profiler
Next Steps
- Implement context splitting in your current app
- Try Zustand for your next feature
- Add optimistic updates to your most-used mutations
- Profile your app and find performance bottlenecks
Member Benefit: Access our State Management Starter Kit with pre-built patterns and templates.
Questions? Join our member community to discuss with other developers.