Splitting State Responsibilities Between React Query and Zustand
Table of Contents
Where it started #
The schedule page in WYDT started as a small UI mockup. User queries, schedule lists, filters, tags, and create and edit modals were added later.
At first, I thought choosing one state management tool would also settle the structure. The more useful question was where the source of each state lived.
The server owns the schedule list. Another screen or user can change it, so the value in the current browser can become stale. The server does not need to know which filter is selected or whether an edit modal is open.
I used the following boundary.
- React Query manages data whose source of truth is the server.
- Zustand manages UI state that only matters in the current browser.
- The same schedule list is not copied into both stores.
The React documentation calls this choosing a single source of truth for each piece of state. It does not mean that all state must live in one place. It means that the same state should not have multiple owners.
Schedules from the server stay in React Query #
A schedule query involves more than its response. It also has loading and error states, a cache lifetime, and rules for when to fetch again. After a create or update operation, the current list may no longer be fresh.
The TanStack Query documentation describes the library as managing asynchronous state between the server and client. Queries fetch and cache data. Mutations change server state.
The examples below are not copies of the original WYDT files. They reduce the same responsibilities to the current TanStack Query v5 syntax.
const schedulesQuery = useQuery({
queryKey: ["schedules", userId],
queryFn: () => getSchedules(userId),
enabled: Boolean(userId),
})
After an update, I do not manually edit a schedule array in Zustand. A successful mutation invalidates the related query and lets it synchronize again.
const queryClient = useQueryClient()
const updateScheduleMutation = useMutation({
mutationFn: updateSchedule,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["schedules", userId],
})
},
})
invalidateQueries marks matching queries as stale and can refetch active queries. This removes the need to keep a separate global array synchronized by hand.
If the server returns the complete updated schedule, setQueryData can update the cache directly. Either way, the Query cache remains the single owner of schedule data.
Filters and modals stay in Zustand #
Filters and modals behave differently. Selecting a work tag or closing an editor does not require another server request.
Several schedule components need these values, but the values only matter inside the current interface. The Zustand store keeps this state and the actions that change it.
type ScheduleUiState = {
selectedTag: string | null
editingScheduleId: string | null
setSelectedTag: (tag: string | null) => void
openEditor: (scheduleId: string) => void
closeEditor: () => void
}
export const useScheduleUiStore = create<ScheduleUiState>()((set) => ({
selectedTag: null,
editingScheduleId: null,
setSelectedTag: (selectedTag) => set({ selectedTag }),
openEditor: (editingScheduleId) => set({ editingScheduleId }),
closeEditor: () => set({ editingScheduleId: null }),
}))
Components select only the state they use.
const selectedTag = useScheduleUiStore((state) => state.selectedTag)
const schedules = schedulesQuery.data ?? []
const visibleSchedules = selectedTag
? schedules.filter((schedule) => schedule.tags.includes(selectedTag))
: schedules
The Zustand documentation uses selectors as the basic way to subscribe to specific state. Reading schedules from React Query and only the filter from Zustand makes the boundary visible in the component.
I do not put the same data in both stores #
The structure I wanted to avoid was copying a React Query result into Zustand.
// Avoided structure
const { data } = useQuery({ ... })
useEffect(() => {
setSchedules(data ?? [])
}, [data, setSchedules])
This creates a new question: is the Query cache or Zustand current? A successful update, a background refetch, and returning to the page all require synchronization code.
The page combines the two states without copying them. Schedules belong to the server. The selected filter belongs to the browser. Combining them while rendering is enough.
Not every filter belongs in Zustand. If a filter must survive a reload or be shared in a URL, search params may be a better owner. I prefer to decide how long a state should live before choosing where to store it.
Conclusion #
Using React Query and Zustand together was not the difficult part. Deciding ownership was.
Server-owned data stayed in React Query. Interface interactions stayed in Zustand. Avoiding duplicated data made the update and synchronization flow easier to follow.