ZEPHYRUS
000
← ARCHIVE
12 Apr 2026#Zustand #ReactQuery #StateManagement #Performance #Optimisation

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.