Using Zustand as a Custom Client Cache
When React Query's server state model doesn't quite fit, a Zustand store with a custom cache map gives you instant switching with targeted invalidation.
React Query is excellent at what it does: keeping server state fresh, deduplicating requests, and handling loading/error states. For some modules in the app I use useQueries and it works great.
But for the primary dashboard, I went a different route a Zustand store with a manual key-value cache. Here’s why, and how it’s built.
The Problem
The dashboard is the most heavily used view in the app by far. Users switch between different contexts/views/actions frequently, accessing data and pipelines that most likely hasn’t changed since their last visit.
With React Query, every context switch would trigger a fresh fetch unless you tuned staleTime carefully. With a manual Zustand cache, the second time you load a context it’s instant no loading state, no flicker, no spinner.
The tradeoff: you own the invalidation logic. This is a double-edged sword, but for data that is relatively stable and doesn’t change often, it’s a reasonable bet.
The Cache Shape
The store holds a contextCache map keyed by context ID string, think of it like a large hashmap holding nested json values:
type StoreState = {
loading: boolean;
tags: string[];
templates: Template[];
users: User[];
activeContextId: string | number | null;
contextCache: Record<
string,
{ tags: string[]; templates: Template[]; users: User[] }
>;
fetchForContext: (
contextId: string | number,
force?: boolean,
signal?: AbortSignal,
) => Promise<void>;
fetchAll: (force?: boolean, signal?: AbortSignal) => Promise<void>;
refreshActive: () => Promise<void>;
getExpiringItems: (daysWindow?: number) => ExpiringItem[];
};
The active tags, templates, and users at the top level are the “current view” whatever context is active right now. contextCache is the backing store for all previously loaded contexts.
The Fetch Logic
flowchart TD
A[fetchForContext called] --> B{contextCache has this key?}
B -- Yes, force=false --> C[Copy from cache to active state\nreturn immediately]
B -- No, or force=true --> D[set loading=true]
D --> E[Parallel API calls\ntags + templates + users]
E --> F[set active state + update contextCache]
F --> G[set loading=false]
fetchForContext: async (contextId, force = false, signal) => {
const key = String(contextId)
const { contextCache } = get()
localStorage.setItem("activeContext", key)
// Cache hit — instant return
if (!force && contextCache[key]) {
const { tags, templates, users } = contextCache[key]
set({ activeContextId: key, tags, templates, users })
return
}
set({ loading: true, activeContextId: key })
try {
const [tagsRes, templatesRes, usersRes] = await Promise.all([
api.get("/tags", { signal }),
api.get("/templates", { signal }),
api.get("/users", { signal }),
])
const tags = tagsRes.data
const templates = templatesRes.data
const users = usersRes.data
set((state) => ({
tags,
templates,
users,
contextCache: {
...state.contextCache,
[key]: { tags, templates, users },
},
}))
} catch (e) {
...
} finally {
set({ loading: false })
}
},
The three API calls run in parallel Promise.all rather than sequential awaits. A custom header on the api instance handles context scoping, so the endpoints themselves don’t change.
Targeted Cache Invalidation After Mutations
This is where owning the cache pays off. When a record is updated via a mutation, I need to invalidate exactly one context’s cache entry and re-fetch not blow away everything:
onSuccess: async (_data, variables) => {
const store = useDataStore.getState();
const activeContextId = store.activeContextId;
if (activeContextId) {
const key = String(activeContextId);
// remove JUST this context's cache entry
const { contextCache } = store;
const newCache = { ...contextCache };
delete newCache[key];
useDataStore.setState({ contextCache: newCache });
// Force fresh fetch for the active context only
await store.fetchForContext(activeContextId, true);
}
};
With React Query you’d do queryClient.invalidateQueries({ queryKey: ["items", activeContextId] }). The Zustand equivalent is two lines more, but the control is explicit no risk of accidentally invalidating queries from other modules.
The “Global” View
When a user selects the multi view, fetchAll is called instead of fetchForContext. This hits a different endpoint that returns data across the entire org, and caches under the "all" key:
fetchAll: async (force = false, signal) => {
const key = "all";
const { contextCache } = get();
if (!force && contextCache[key]) {
const { tags, users } = contextCache[key];
set({ activeContextId: key, tags, users });
return;
}
set({ loading: true, activeContextId: key });
const [tagsRes, usersRes] = await Promise.all([
api.get("/tags", { signal }),
api.get("/users/all", { signal }),
]);
// ...
};
Same cache pattern, different endpoint. The "all" sentinel is consistent across the store, the API client interceptor, and the UI layer.
Zustand vs React Query: When to Pick Which
| Criterion | Zustand cache | React Query |
|---|---|---|
| Instant context switching | Yes — synchronous read from cache | Needs staleTime tuning |
| Background refetching | Manual (refreshActive) |
Automatic |
| Deduplication | Manual (check loading flag) | Automatic |
| Mutation invalidation | Surgical delete + force refetch | invalidateQueries |
| Cross-module shared state | Easy — subscribe from anywhere | Needs shared query key |
| DevTools | Zustand devtools | React Query devtools |
In this project, both patterns coexist. The primary dashboard uses Zustand because context-switching speed is critical and the data model is well understood. Other modules use useQueries because the data is more naturally query shaped and the fetch per context parallelism is built in.
Ideally in production a multiple of these techniques is used to gain ease of implementation and fine grain control when needed. This is a crucial factor that differentiates between your servers being hit with large traffic spikes when users are active vs uniform traffic that is distributed.
Neither is universally better. The deciding question is whether you need the cache to be a first-class part of your application state, or whether you want the query layer to own it. In the end if you are unsure, both is the answer.