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:
- the app becomes globally mutable and hard to reason about
- 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 type | Owner | Examples | Best home |
|---|---|---|---|
| Server state | backend or API | products, profile, entitlements, orders | query/cache layer |
| UI state | component subtree | modal open, selected row, hovered card | local component state |
| URL state | router | search query, sort, tab, page cursor | route/search params |
| Workflow state | screen or feature | checkout step, approval flow, bulk edit | reducer or state machine |
| Optimistic client intent | frontend until server confirms | temporary toggle, optimistic list insert | mutation layer + local overlay |
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.