At work I inherited an internal resource planner: about 500 people used it to plan who works on what. I was the only frontend developer on it, and the codebase was old.
Every piece of data in that app lived in Redux Toolkit. Employees, projects, weekly allocations, departments: each had a slice, a set of thunks, and hand-written isLoading and error flags. It worked, technically. It also refetched everything all the time, showed loaders in the wrong places, and sometimes showed two screens that disagreed with each other.
The fix wasn't a better Redux setup. It was realizing that most of what we kept in Redux wasn't app state at all.
What it looked like
A typical slice, simplified:
export const fetchEmployees = createAsyncThunk('employees/fetch', async (departmentId: string) => {
const { data } = await api.get(`/departments/${departmentId}/employees`);
return data;
});
const employeesSlice = createSlice({
name: 'employees',
initialState: { items: [], isLoading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchEmployees.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchEmployees.fulfilled, (state, action) => {
state.isLoading = false;
state.items = action.payload;
})
.addCase(fetchEmployees.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message;
});
},
});
And every component that needed the data:
useEffect(() => {
dispatch(fetchEmployees(departmentId));
}, [departmentId]);
Multiply that by every entity in the app. A lot of code, and all of it was solving the same problem badly.
Four symptoms of one problem
1. The same data, fetched again and again. Page one showed data A and B. Page two showed B and C. Open page two right after page one, and B was requested again, even though we got it a second ago. Each page's useEffect had no idea the data was already there.
2. Every tab switch was a new request. Switching tabs unmounted one component and mounted another. Mount meant useEffect, and useEffect meant a request. Go back and forth three times, get six requests and six spinners.
3. Loaders that lied. One isLoading flag per slice, shared by every request to that slice. Two requests in flight: the first one finishes, sets isLoading = false, and the loader disappears while the second is still loading. Or the opposite: a background refresh flips the flag and replaces content that was already on screen with a spinner.
4. Screens that disagreed. After someone changed an allocation, the page they were on updated. The summary page didn't, because it had its own copy of that data in another slice, and nothing told it to refresh. You only saw the right numbers after a reload.
These look like four bugs. They're one: we were building a cache by hand, without any of the things a cache needs.
Two kinds of state
The data in that app came in two very different kinds.
| Server state | Client state | |
|---|---|---|
| Examples | employees, projects, allocations | selected week, open modal, filters, active tab |
| Who owns it | the server | the browser |
| Can it go stale? | yes, someone else can change it | no, only this user changes it |
| What it needs | caching, dedup, refetching, invalidation | a place to live and a way to update it |
Server state is a copy of data that lives somewhere else. The questions you have to answer for it are hard: how fresh is this copy, who else is asking for the same thing right now, what do we refetch after a change. Client state is the opposite: synchronous, owned by you, never stale.
Redux is a good tool for the second kind. For the first kind, putting server data in Redux means writing caching, request deduplication and invalidation yourself, in every slice. We hadn't. Hence the four symptoms.
The fix: one tool for each kind
I moved server state to TanStack Query and the little client state that was left to Zustand.
The same employees data with TanStack Query:
export const employeeKeys = {
all: ['employees'] as const,
byDepartment: (departmentId: string) => ['employees', departmentId] as const,
};
export function useEmployees(departmentId: string) {
return useQuery({
queryKey: employeeKeys.byDepartment(departmentId),
queryFn: () => api.get(`/departments/${departmentId}/employees`).then((r) => r.data),
staleTime: 60_000,
});
}
That's the whole thing. No slice, no thunk, no flags. And each symptom went away for a specific reason.
Duplicate requests. The query key identifies the data. Any component on any page that asks for ['employees', 'design'] gets the cached result. If two components ask at the same moment, TanStack Query sends one request.
Tab switches. staleTime says how long the data counts as fresh. Inside that window, switching tabs shows the cached data instantly, with no request at all. After it, the cached data shows instantly and refreshes in the background.
Lying loaders. Every query has its own status, so one request can't hide another one's loader. And TanStack Query separates two states I'd been mixing up:
const { data, isPending, isFetching } = useEmployees(departmentId);
if (isPending) return <TableSkeleton />; // first load, nothing to show yet
return (
<>
{isFetching && <RefreshIndicator />} {/* background refresh, keep the table */}
<EmployeesTable rows={data} />
</>
);
A skeleton only when there's nothing to show. A small indicator when we're refreshing data the user can already see.
On screens with a period switcher, placeholderData: keepPreviousData keeps the current period on screen while the next one loads, instead of flashing an empty table.
Screens that disagree. There's one copy of each piece of data now, so there's nothing to get out of sync. After a change, the mutation tells the cache what's outdated:
export function useUpdateAllocation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (allocation: Allocation) => api.put(`/allocations/${allocation.id}`, allocation),
// Returning the promise keeps the mutation pending until fresh data is in.
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['allocations'] }),
queryClient.invalidateQueries({ queryKey: ['summary'] }),
]),
});
}
Returning the promise matters: the mutation stays pending until the fresh data arrives, so the Save button stays disabled and the old numbers never flash back.
Every screen showing allocations or the summary refetches. The summary page is right without a reload.
What was left for Zustand
Once the server data moved out, the "global store" turned out to be tiny: the selected week, the active department tab, a few filters, which modal is open.
type PlannerUiState = {
week: string;
departmentId: string;
setWeek: (week: string) => void;
setDepartment: (departmentId: string) => void;
};
export const usePlannerUi = create<PlannerUiState>()((set) => ({
week: currentWeek(),
departmentId: 'all',
setWeek: (week) => set({ week }),
setDepartment: (departmentId) => set({ departmentId }),
}));
The two layers meet in the component: client state picks what to show, server state fetches it.
const departmentId = usePlannerUi((s) => s.departmentId);
const { data } = useEmployees(departmentId);
"Why not RTK Query?"
Fair question. RTK Query solves the same problem in the same way: cached queries, tags for invalidation, per-request status. If your app is already deep in Redux, moving server data to RTK Query is probably the cheaper step, and it would have fixed all four symptoms too.
I picked TanStack Query and Zustand because I find them simpler to read and write: less ceremony per query, and a client store that's a plain hook. That's taste. The part that isn't taste is the split. Whatever you use, don't keep server data in the same place and the same way as UI state.
How I migrated without stopping feature work
-
Add TanStack Query next to Redux. Wrap the app in
QueryClientProviderand change nothing else. - Move the most shared read data first. Data that several pages show gives the biggest win, like employees or departments.
- One entity at a time. Write the query hook, switch components to it, delete the thunk and the slice. Ship.
- Move mutations together with their queries, so invalidation covers every screen that shows the data.
- Move what's left to Zustand at the end. By then it's a handful of fields.
Two mistakes to avoid on the way:
-
Don't copy query data into a store.
useEffect(() => setEmployees(data), [data])brings back the two-copies problem you just removed. Read from the query wherever you need the data. - Put every parameter in the query key. If the request depends on the week and the department, both go in the key. Otherwise two different requests share one cache entry.
The result
No more duplicate requests. Tab switches show cached data instantly. Loaders appear only when there's really nothing on screen. And after an edit, every screen shows the same numbers without a reload. As a bonus, a big chunk of the codebase was simply deleted: thunks, extraReducers, loading flags, mount effects.
The biggest change wasn't the libraries. It was asking one question about every piece of state: who owns this? If it's the server, it's a cache, and caching is a solved problem. Use the solution.
How do you split state in your projects? Still everything in one store, or already separated?
Top comments (2)
One detail on useUpdateAllocation: the invalidateQueries calls in onSuccess aren't returned, so the mutation finishes before the refetch does and the screen can show the old numbers for a moment. Returning Promise.all of the invalidations keeps the mutation pending until fresh data is in, which is also what you want if the Save button should stay disabled.
good catch thanks! you’re right without returning the promise mutation is done before refetch and old numbers can flash for a moment. updated the snippet to return Promise.all so save button stays disabled until fresh data is in