Field note / react

publishedupdated 2026-05-04#react#state-management#tanstack-query#server-state#ui-state

React Server State vs UI State

How I separate server state, UI state, URL state, and workflow state so large React apps stay predictable under load.

TL;DR

  • Server state and UI state should almost never live in the same abstraction.
  • I treat fetched data, cache freshness, retries, and invalidation as a data-layer problem, not a reducer problem.
  • UI state should stay local or route-scoped until the app proves it needs broader coordination.
  • The biggest React state mistake I see is duplicating remote truth in a client store and then debugging drift forever.

Context

As React apps grow, the word “state” gets overloaded. Teams start putting everything into one bucket:

  • API responses
  • modal visibility
  • selected filters
  • optimistic draft edits
  • request status
  • wizard step transitions

That usually creates one of two failures:

  1. the app becomes globally mutable and hard to reason about
  2. the team starts synchronizing the same value in three places

The way out is to split state by ownership and by failure mode.

The Approach

1. Classify state before choosing a tool

This is the taxonomy I reach for most often:

State typeOwnerExamplesBest home
Server statebackend or APIproducts, profile, entitlements, ordersquery/cache layer
UI statecomponent subtreemodal open, selected row, hovered cardlocal component state
URL stateroutersearch query, sort, tab, page cursorroute/search params
Workflow statescreen or featurecheckout step, approval flow, bulk editreducer or state machine
Optimistic client intentfrontend until server confirmstemporary toggle, optimistic list insertmutation layer + local overlay
I decide state ownership first, then I decide the implementation detail.

2. Keep server state in a data layer

For server state, I want:

  • caching
  • deduplication
  • retries
  • stale/fresh semantics
  • invalidation
  • background refetch

That is why I do not want to build it from scratch in useReducer.

import { useQuery } from '@tanstack/react-query'

function ProductPanel({ productId }: { productId: string }) {
  const productQuery = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    staleTime: 30_000,
  })

  if (productQuery.isLoading) return <ProductSkeleton />
  if (productQuery.isError) return <ErrorState />

  return <ProductView product={productQuery.data} />
}

This keeps the view focused on rendering. The data layer owns freshness.

3. Do not copy fetched data into a client-global store by default

This is the anti-pattern I still see in large codebases:

const product = useQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id) })
const setProduct = useProductStore((state) => state.setProduct)

useEffect(() => {
  if (product.data) {
    setProduct(product.data)
  }
}, [product.data, setProduct])

Now the app has two truths:

  • the cache
  • the store

The cost shows up later:

  • stale product data after mutation
  • update ordering bugs
  • extra selectors and synchronization effects
  • unnecessary rerenders

If the data layer already owns the value, I only mirror it when I truly need a client-only projection or offline-first workflow.

4. Keep UI state narrow

For UI state, smaller scope is usually better.

function FiltersPanel() {
  const [isOpen, setIsOpen] = useState(false)
  const [draftSearch, setDraftSearch] = useState('')

  return (
    <>
      <button onClick={() => setIsOpen((value) => !value)}>Filters</button>
      {isOpen && <input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} />}
    </>
  )
}

The mistake is promoting this too early into a shared store just because a team expects complexity later.

5. Treat optimistic state as an overlay, not replacement truth

Optimistic UI works well when rollback is understandable.

const queryClient = useQueryClient()

const toggleSaved = useMutation({
  mutationFn: saveProduct,
  onMutate: async ({ productId, saved }) => {
    await queryClient.cancelQueries({ queryKey: ['product', productId] })
    const previous = queryClient.getQueryData(['product', productId])

    queryClient.setQueryData(['product', productId], (current: any) => ({
      ...current,
      saved,
    }))

    return { previous }
  },
  onError: (_error, variables, context) => {
    queryClient.setQueryData(['product', variables.productId], context?.previous)
  },
  onSettled: (_data, _error, variables) => {
    queryClient.invalidateQueries({ queryKey: ['product', variables.productId] })
  },
})

I like this because the overlay is explicit and rollback is not hand-waved.

6. Use URL state for user-restorable views

If the user expects refresh, copy-link, or back/forward to preserve the state, I move it into the URL.

Real examples:

  • search query
  • selected tab
  • sort order
  • page cursor
  • active facet filters

That is especially important for admin tools, search surfaces, and dashboards.

Trade-offs

  • Data layers reduce boilerplate and state drift, but they introduce their own concepts around stale time, cache keys, and mutation lifecycles.
  • Narrow UI state is easier to reason about, but teams sometimes overshare state because they expect cross-page reuse too early.
  • URL state improves restore and share behavior, but it forces discipline around serialization and route design.
  • Optimistic state improves responsiveness, but it is dangerous when rollback rules are unclear.

References