learn()
{ start }
build++
const tutorial

Advanced State Management Patterns for Production Apps

Deep dive into advanced state management patterns, context optimization, and avoiding common pitfalls in production React applications.

Cuppa Team8 min read
reactstate-managementcontextperformanceadvanced

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

  1. Split contexts by domain - Don't put everything in one context
  2. Use selectors - Only subscribe to what you need
  3. Consider Zustand - For complex state, use a proper state library
  4. Optimistic updates - Make your app feel instant
  5. 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.