Moe Chaieb

You might not need Zustand

July 2026

The plugin UIs I build with time off audio are React apps running inside a JUCE webview, and Zustand was how they mirrored the state of the audio engine: a ParameterStore, a PresetStore, an InfoStore, an AuthStore, and so on. The collection eventually grew to eight stores.

That remained the case from the time I launched in 2024, to earlier this year.

Today there are zero. Not because Zustand failed me (it's a great library), but because I eventually realized almost none of that state was the UI's to store. It simply had to mirror it.

Where does the state actually live?

When you reach for a state management library, the first question shouldn't be "which one?" It should be: where is this state's source of authority?

Is the current client the authority on this value, or is it merely holding a copy of something owned elsewhere, like a a server, a database, a native process? If it's the latter, you're dealing with remote state, and the client only ever has a view of it.

That distinction matters because a view comes with obligations that owned state doesn't have. A good abstraction for remote state has to handle:

  • The view not existing yet. The data lives elsewhere, so there is always a window where the client doesn't have it. That's your loading state, and it needs to be a first-class concept, not an isLoading boolean you bolt onto a store.
  • The view being outdated. Someone else can change the state under you. You need invalidation, refetching, and cache lifetimes.
  • The DX around all of this. Deduplicating in-flight requests, sharing cached results across components, optimistic updates and cache invalidation on mutations.

Zustand gives you none of this, because it's not what it's for. Zustand answers "how do I share state between components?". It says nothing about state whose authority lives across a network boundary. If you use it for remote state, you end up writing the synchronization layer yourself: the fetch-on-mount effects, the loading flags, the staleness logic, the push handlers calling store.setState(). I know because I wrote all of it, eight times.

TanStack Query is the canonical solution here. Loading and error states are part of every query's type. Caching, invalidation, and background refetching are the core of the library rather than an afterthought. And it works on both sides of the rendering boundary: you can prefetch on the server, hydrate the cache, and keep fetching on the client with the same query definitions.

Example 1: the plugin UIs

Here's the part I find most interesting: in my plugin UIs, the "remote" isn't a server. It's the C++ audio engine on the other side of the webview bridge. There's no HTTP involved. But the source of authority for a parameter's value is unambiguously the audio processor (i.e. the actual VST3/AudioUnit instance in the DAW). The host automation, preset loads, and other plugin instances all mutate it without asking the UI. The UI holds a view. That makes it remote state, no matter how local it feels.

The original Zustand version created a store per parameter and hand-rolled the synchronization:

const createSingleParameterStore = (parameter: Parameter) =>
  create<SingleParameterState>()((set, get) => ({
    ...parameter,
    setValue: async (newValue: number) => set({ value: newValue }),
    startChangeGesture: async () => {
      if (get().gestureStarted) return;
      set({ gestureStarted: true });
      await juce_startParameterChangeGesture(parameter.uid);
    },
    // ...and so on: end gestures, display values, sync callbacks from C++
  }));

Every concern I listed above—initial load, staleness, pushes from the engine—was my problem to solve, scattered across stores and C++-facing callback registrations.

Today, the C++ backend is exposed to the UI as a tRPC router (the bridge transport is custom, but tRPC doesn't care)1, and every component subscribes through React Query:

function useParameter(uid: string): Parameter | undefined {
  const { data } = useQuery(trpc.parameter.value.get.queryOptions(uid));
  return data;
}

When the engine pushes a change (host automation, preset load), the handler is one line: queryClient.setQueryData(key, nextValue). Every component reading that parameter re-renders; nothing else does. Loading states come for free. Concurrent reads of the same parameter are deduplicated for free. The eight stores became a folder of hooks that are mostly just useQuery calls with good keys.

Example 2: the website

It's not just the plugin UIs. The timeoff.audio storefront tells the same story with a more conventional remote: the cart, catalog, user, and licenses all live on the commerce backend (powered by Moonbase). The client has a view.

export const useCart = () => {
  const trpc = useTRPC();
  return useQuery(trpc.moonbase.cart.get.queryOptions(cartId));
};

Mutations write the server's response straight back into the cache, so every component sees the update without any store to keep in sync:

const useStorefrontMutationOptions = () => ({
  onSuccess: ({ catalog, cart, user }) => {
    if (catalog) queryClient.setQueryData(catalogKey, catalog);
    if (cart) queryClient.setQueryData(cartKey, cart);
    if (user) queryClient.setQueryData(userKey, user);
  },
});

Even "is the cart busy?" isn't store state. It's derived from the query client itself via useIsMutating.

What's left over

After the remote state moved into React Query, what remained was genuinely small.

One caveat before reaching for useState, though: some of that leftover local state needs to live in links. If a piece of UI state should survive a refresh, be shareable, or be targetable from an email or a marketing page, its home is the URL, not component memory. On the website, whether the cart dialog is open is exactly that kind of state: I want to be able to link someone straight into their open cart. For URL state, nuqs is the best tool I've found: it gives you the useState API with the URL as the backing store, so the cart dialog is just a useQueryState in the component that owns it.

The rest is plain local state. In the plugin UIs: things like the UI scale factor, which several deeply-nested components need. That's a small Context provider:

const UiScaleContext = createContext<number>(1);

export const UiScaleProvider = ({ value, children }) => (
  <UiScaleContext.Provider value={value}>{children}</UiScaleContext.Provider>
);

export const useUiScale = () => useContext(UiScaleContext);

That's it. Once the state that was actually remote found its proper home, there was nothing left that justified a store.

In conclusion

Your mileage may vary. If you move your remote state into React Query and still find yourself with lots of genuinely client-owned, interconnected state. If you're building a game, a complex editor, a canvas tool, then Zustand is exactly the right tool, and you should use it without guilt.

But run the audit first. For each piece of state, ask who the authority is. If the answer is "somewhere else", it's remote state, and it belongs in TanStack Query. You might find, as I did in two very different codebases, that what's left fits comfortably in useState, the URL, and a Context or two.

Footnotes

  1. Using tRPC for a C++ backend may seem like overkill, and it largely is. But I wanted to build the UI in a way that was agnostic to the backend being a C++ plugin. Who knows, I could be working on fully web-based audio apps at some point in the future. With this decision, all I would need is to swap the custom transport with a websocket one, and I'd be pretty much all set. The rest of the stack would remain exactly the same.