DEV Community

Cover image for Your Redux Store Is Not a Cache
Andrei Kornev
Andrei Kornev

Posted on Edited on

Your Redux Store Is Not a Cache

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;
      });
  },
});
Enter fullscreen mode Exit fullscreen mode

And every component that needed the data:

useEffect(() => {
  dispatch(fetchEmployees(departmentId));
}, [departmentId]);
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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} />
  </>
);
Enter fullscreen mode Exit fullscreen mode

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'] }),
      ]),
  });
}
Enter fullscreen mode Exit fullscreen mode

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 }),
}));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

"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

  1. Add TanStack Query next to Redux. Wrap the app in QueryClientProvider and change nothing else.
  2. Move the most shared read data first. Data that several pages show gives the biggest win, like employees or departments.
  3. One entity at a time. Write the query hook, switch components to it, delete the thunk and the slice. Ship.
  4. Move mutations together with their queries, so invalidation covers every screen that shows the data.
  5. 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)

Collapse
 
101beardo profile image
Tarun Sharma •

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.

Collapse
 
andrei-kornev-frontend profile image
Andrei Kornev • • Edited

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