DEV Community

Atlas Whoff
Atlas Whoff

Posted on

React State Management Patterns That Scale: Zustand, URL State, and Server State Separation

Most developers write React components reactively — state changes, component re-renders, done. This works until you have 50 components, cross-component state dependencies, and performance problems you can't trace.

These patterns give your state a structure that scales.

Colocation First

Before reaching for any state management library, ask: does this state need to be shared? If not, keep it local:

// Good -- dropdown state lives where it's used
function UserMenu() {
  const [isOpen, setIsOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Menu</button>
      {isOpen && <DropdownItems />}
    </div>
  )
}

// Bad -- dropdown state in global store
const useStore = create(set => ({
  isMenuOpen: false,
  setMenuOpen: (v) => set({ isMenuOpen: v }),
}))
Enter fullscreen mode Exit fullscreen mode

Global state is for data that is genuinely shared across distant components: auth user, theme, cart contents, notifications.

URL as State

Search filters, pagination, selected tabs — these belong in the URL:

import { useSearchParams } from 'next/navigation'

function ProductList() {
  const searchParams = useSearchParams()
  const page = Number(searchParams.get('page') ?? '1')
  const category = searchParams.get('category') ?? 'all'
  const sort = searchParams.get('sort') ?? 'newest'

  // Filters persist on refresh, are shareable, and work with browser back
  const products = useProducts({ page, category, sort })
  // ...
}
Enter fullscreen mode Exit fullscreen mode

URL state is free persistence, shareability, and browser history integration. Use it before reaching for useState.

Server State is Not Application State

Data from your API should be managed by a fetching library, not manually:

// Bad -- manual server state
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

useEffect(() => {
  setLoading(true)
  fetchUser(id).then(setUser).catch(setError).finally(() => setLoading(false))
}, [id])

// Good -- React Query handles all of this
const { data: user, isLoading, error } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 5 * 60 * 1000, // 5 min cache
})
Enter fullscreen mode Exit fullscreen mode

React Query / SWR give you caching, deduplication, background refetching, optimistic updates, and error handling for free.

Zustand for Client State

When you do need global client state, Zustand is the leanest option:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface CartStore {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  clear: () => void
  total: () => number
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) => set(state => ({ items: [...state.items, item] })),
      removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
      clear: () => set({ items: [] }),
      total: () => get().items.reduce((sum, item) => sum + item.price, 0),
    }),
    { name: 'cart-storage' } // persists to localStorage
  )
)
Enter fullscreen mode Exit fullscreen mode

Avoiding Re-render Cascades

When a Zustand store updates, only components that use the changed slice re-render:

// Bad -- subscribes to entire store, re-renders on any change
const store = useCartStore()

// Good -- subscribes only to items count
const itemCount = useCartStore(state => state.items.length)

// Good -- stable selector with shallow comparison
import { shallow } from 'zustand/shallow'
const { addItem, removeItem } = useCartStore(
  state => ({ addItem: state.addItem, removeItem: state.removeItem }),
  shallow
)
Enter fullscreen mode Exit fullscreen mode

Context for Dependency Injection

Context is not a state manager — it's a dependency injection tool:

// Good use of context -- stable config values
const ThemeContext = createContext<Theme>('light')
const AuthContext = createContext<AuthUser | null>(null)

// Bad use of context -- frequently changing data causes mass re-renders
const CartContext = createContext<CartState>(...) // use Zustand instead
Enter fullscreen mode Exit fullscreen mode

If the value in context changes frequently, every consumer re-renders. Use Zustand or React Query for dynamic data.


The AI SaaS Starter at whoffagents.com ships with Zustand + React Query pre-configured, correct selector patterns, and a dashboard that demonstrates these patterns at production scale. $99 one-time.

Top comments (0)