Reimplementing Zustand with Built-in Async State
I’ve been using Zustand for years. One thing I’ve always loved is how simple it feels. You can read and update state anywhere, not just inside React components.
const useTheme = create(() => ({ theme: 'dark' }));
// Inside componentfunction MyComponent() { const { theme } = useTheme();}
// Outside componentconst { theme } = useTheme.getState(); // readuseTheme.setState({ theme: 'light' }); // updateI really liked how simple that API felt. After using it for a long time, I started wondering how Zustand actually worked. So I started reading its source code.
To my surprise, the core idea was much simpler than I had imagined. It was essentially an implementation of the Observer Pattern. The store lives outside React, while React simply subscribes to it through a hook. Of course, there are additional features on top of that, such as partial updates, selectors, and shallow comparison, but the core idea is still simple.
Once I understood how to build a global state manager like Zustand, another question came to mind. Could the same idea be applied to asynchronous state? I believed it should be possible.
I’ve also been using TanStack Query for a long time, back when it was still called react-query. At that time, most people were still using Redux, MobX, or Apollo Client for GraphQL applications.
Over the years, I’ve become very familiar with its API, how query states change over time, and many of the edge cases it has to handle. I already had a rough idea of what an async state manager would need to solve, including caching, stale data, request deduplication, invalidation, retries, etc.
Starting with the Developer Experience
The first thing I thought about was not the implementation.
I started by thinking about the developer experience.
I’ve actually been quite happy with TanStack Query’s developer experience. Still, I wanted to experiment with a different approach. I wondered what it would feel like if asynchronous state had the same developer experience as Zustand, where reading state, updating it, or performing other actions outside React components feels effortless.
I knew TanStack Query already lets us do that through queryClient, at least in non-SSR applications where the queryClient is shared globally.
queryClient.getQueryData(['user']);queryClient.setQueryData(['user'], data);queryClient.refetchQueries({ queryKey: ['user'] });queryClient.invalidateQueries({ queryKey: ['user'] });The only difference is that every query is referenced by its query key instead of a store variable.
I completely understand why it’s designed that way. I see it as a trade-off rather than a drawback. Since every query lives inside a centralized queryClient, using query keys is simply a consequence of that architecture.
Instead of referencing every query by its query key, what if every query could simply become its own store?
Something like this…
const useUserQuery = createQuery( getUser, // Query function {}, // Config, like staleTime, gcTime);Inside a React component, it should feel just like any other hook.
const { isLoading, data, error } = useUserQuery();Outside React, it should feel just like Zustand.
const user = useUserQuery.getState().data;
useUserQuery.invalidate();This was exactly the developer experience I had in mind.
Then I realized something. Some queries need parameters!
Supporting Parameterized Queries
Fetching a user profile is straightforward. But many real-world queries require parameters. Fetching a product detail usually requires a product ID. Listing products might require filters, pagination, or sorting options.
That raised an interesting design question. Where should the parameters be passed? Should they be provided when creating the query? When calling the hook? Or should every API accept the parameters separately?
I wanted the API to feel consistent regardless of whether a query needed parameters or not. After trying several different ideas, I eventually settled on this design.
const userQuery = createQuery(getUser);const productQuery = createQuery(getProduct);Queries without parameters remain simple.
// Inside componentfunction UserProfile() { const useUserQuery = userQuery(); const { isLoading, data } = useUserQuery();}
// Outside componentuserQuery().getState();userQuery().invalidate();For parameterized queries, the parameters are provided when selecting the query instance.
// Inside componentfunction ProductDetail({ id }) { const useProductQuery = productQuery({ id }); const { isLoading, data } = useProductQuery();}
// Outside componentproductQuery({ id: 1 }).getState();productQuery({ id: 1 }).invalidate();At first glance, the extra function call might look a little unusual. But each call has a different responsibility. The first call selects a query instance, while the second behaves as the React hook.
I no longer had to think about query keys at all. Every query simply became another store, just like Zustand.
At that point, I was happy with the API. Now I had to make it actually work.
Building the Engine
At a high level, building the query store wasn’t actually that complicated. Since I already understood how Zustand worked internally, I simply reused the same idea. Every query store is just an observable store that lives outside React.
The only difference is that instead of storing arbitrary state, it stores query state. I also modeled the state using TypeScript discriminated unions. That part was straightforward.
type QueryState<TData> = | { status: 'pending'; data: undefined; error: undefined } | { status: 'success'; data: TData; error: undefined } | { status: 'error'; data: undefined; error: Error };The first real challenge was supporting parameterized queries.
getProduct({ id: 1 }) and getProduct({ id: 2 }) should not share the same state, and certainly should not share the same cache.
Every parameter combination needs its own store.
A regular Zustand-like store only manages a single piece of state, so I needed a way to manage multiple stores behind a single query.
The obvious solution was a Map.
The next question was: how do we convert query parameters into a Map’s key?
Using JSON.stringify almost works, but there is one important catch.
JSON.stringify({ a: 1, b: 2 });// {"a":1,"b":2}
JSON.stringify({ b: 2, a: 1 });// {"b":2,"a":1}Although both objects represent the same query, they produce different strings because the property order is different.
Instead of inventing my own solution, I borrowed the same stable hashing strategy used by TanStack Query. It sorts object keys before serializing them, ensuring objects with the same content always produce the same key.
export const getHash = (value?: any) => JSON.stringify(value, (_, val) => isPlainObject(val) ? Object.keys(val) .sort() .reduce((result, key) => { result[key] = val[key]; return result; }, {} as any) : val, );
const hasObjectPrototype = (value: any) => { return Object.prototype.toString.call(value) === '[object Object]';};
const isPlainObject = (value: any) => { if (!hasObjectPrototype(value)) return false; const ctor = value.constructor; if (typeof ctor === 'undefined') return true; const prot = ctor.prototype; if (!hasObjectPrototype(prot)) return false; if (!prot.hasOwnProperty('isPrototypeOf')) return false; if (Object.getPrototypeOf(value) !== Object.prototype) return false; return true;};With the basic architecture in place, I could finally start implementing the behaviors of a real async state manager.
Keeping Data Fresh
The first feature I wanted was stale-while-revalidate. To be honest, I don’t think the implementation is particularly difficult. The flow is pretty simple.
Whenever a component uses a query, it always reads data directly from the store, regardless of whether the data is fresh or stale. This allows the UI to render immediately without waiting for a network request.
At the same time, the query decides whether it should trigger a fetch.
- If there isn’t any cached data, fetch the data and store the result.
- If there is cached data, check whether it’s still fresh.
- If it’s still fresh, don’t send another request.
- If it’s stale, trigger a background fetch.
Once the fetch completes, the store is updated, which automatically notifies all subscribed components. They then re-render and receive the latest data from the store.
To support this behavior, I only needed to store one extra piece of information alongside the cached data: dataUpdatedAt, the timestamp of when the data was last successfully fetched.
Whenever I need to determine whether the cache is still fresh, I simply compare the current time with dataUpdatedAt and the configured staleTime.
const execute = requestParam => { const key = getHash(requestParam); const queryStore = stores.get(key);
const { status, dataUpdatedAt } = queryStore.getState();
if (status === 'success') { const isFresh = dataUpdatedAt + staleTime > Date.now(); if (isFresh) return; }
queryFn(requestParam).then(res => { queryStore.setState({ status: 'success', data: res, dataUpdatedAt: Date.now(), error: null, }); });};Avoiding Duplicate Requests
Imagine two components requesting the same query at almost the same time. There’s no reason to send two identical network requests.
The solution is simple.
Whenever a fetch starts, I store the pending Promise.
Then before starting a new request, check whether there’s already a pending promise.
- If there isn’t, start a new request and store its promise.
- If there is, simply reuse the existing promise.
Both callers will receive the same response at the same time, and only one network request is sent.
const pendingRequests = new Map();
const execute = requestParam => { const key = getHash(requestParam);
if (pendingRequests.has(key)) { return pendingRequests.get(key); }
const promise = queryFn(requestParam).finally(() => { pendingRequests.delete(key); });
pendingRequests.set(key, promise); return promise;};Cleaning Up Unused Cache
In TanStack Query, the garbage collection timer only starts when a query becomes inactive, meaning it no longer has any subscribers.
To support the same behavior, I needed a way to know when a query no longer had any subscribers.
Interestingly, solving this led to something I hadn’t planned. I ended up improving the observer store by adding callbacks that are triggered when the first subscriber appears and when the last subscriber leaves.
export function createStore<T>( initialValue: T, options: { onFirstSubscribe?: (state: T) => void; onLastUnsubscribe?: (state: T) => void; } = {},) { // ...
function subscribe(fn: Subscriber<T>) { subscribers.add(fn); if (subscribers.size === 1) { options.onFirstSubscribe?.(state); }
return () => { subscribers.delete(fn); if (subscribers.size === 0) { options.onLastUnsubscribe?.(state); } }; }
// ...}You could call them store events, or maybe store lifecycle hooks.
For query stores, onLastUnsubscribe starts the garbage collection timer, while onFirstSubscribe cancels it if someone subscribes again before the timer expires.
Invalidating Queries
Invalidation is different from revalidation.
Revalidation only cares about one thing: whether the cached data is still fresh.
- If the data is still fresh, do nothing.
- Otherwise, fetch new data.
It doesn’t matter whether anyone is currently using the query.
Invalidation is different because it also needs to know whether the query is active.
- If the query is active, fetch immediately.
- If it’s inactive, simply mark it as invalidated. The next subscriber will trigger the fetch.
That requirement made the store API evolve again. I added another API to expose the current subscriber count.
const storeApi = { getState, setState, subscribe, getSubscriberCount: () => subscribers.size,};const invalidate = () => { if (store.getSubscriberCount() === 0) { store.setState({ isInvalidated: true }); return; } refetch();};Adding invalidation also changed how stale-while-revalidate worked. Previously, it only checked whether the cached data was still fresh. Now it also checks whether the query has been invalidated. If a query is invalidated, it should always fetch data, even if the cached data hasn’t become stale yet.
const { status, dataUpdatedAt, isInvalidated } = queryStore.getState();
if (status === 'success' && !isInvalidated) { const isFresh = dataUpdatedAt + staleTime > Date.now(); if (isFresh) return;}
queryFn(requestParam).then(res => { queryStore.setState({ status: 'success', data: res, dataUpdatedAt: Date.now(), isInvalidated: false, error: null, });});Handling Race Condition
What if the user manually refreshes while a background refetch is already running? Should the request be deduplicated? Or should it always send a new request?
Neither answer is always correct. So I made it configurable.
- System-triggered refetches always reuse pending requests.
- User-triggered refetches are configurable, but by default they create a new request.
That felt like the most intuitive behavior.
Fine-Grained Reactivity
The last thing I wanted to improve wasn’t about asynchronous state. It was rendering performance.
With Zustand, we usually use selectors so components only re-render when the selected slice changes.
TanStack Query takes a different approach. It automatically tracks which properties are accessed during render, and only re-renders when those specific properties change.
I found that really interesting.
After reading through the implementation, it turned out that the answer was JavaScript’s Proxy.
So I borrowed the same idea.
The result is automatic fine-grained reactivity, without requiring selectors.
Looking Back
I’m really happy with how the project turned out.
I originally started by trying to rebuild Zustand. In the end, I built something that still feels like Zustand, but no longer requires selectors and even supports store lifecycle hooks.
I also wanted to build something similar to TanStack Query, but with a Zustand-like developer experience. The result covers everything I personally use. As a nice bonus, the bundle size also ended up being significantly smaller than TanStack Query.
There are still a few features I intentionally didn’t implement, such as polling, infinite queries, and built-in DevTools.
Polling is a good example. There are many questions that don’t have a single correct answer. Should polling stop when the document becomes hidden? Should it stop when the user goes offline? What should happen if one of the polling requests fails?
Different applications will want different behaviors.
Instead of trying to support every possible combination, I decided to leave polling to userland.
Since query stores already expose lifecycle hooks like onFirstSubscribe and onLastUnsubscribe, implementing custom polling behavior becomes very straightforward.
I made a similar decision for infinite queries. Rather than introducing another dedicated API, I wanted to encourage composition instead. It turns out to be much simpler than I originally expected, and I included an example in the repository.
As for DevTools…
Yeah… nobody got time for that. 😆
More than anything else, this project taught me a lot.
Reading library source code teaches you a lot.
Building one yourself teaches you even more.
Thank you for reading all the way to the end. I hope you found something interesting or useful along the way.
If you’d like to explore the project, here’s the link: yuustate.vercel.app