Simple React Query

A minimal implementation of React Query from scratch. Caching, tag-based invalidation, and automatic revalidation in ~200 lines.

TypeScriptReact

I wanted to understand how React Query works under the hood. Not the API surface, but the actual mechanics. How does caching work? How does it know when to refetch? How does invalidation propagate?

So I built one. ~200 lines of TypeScript, no dependencies.

The cache

The core is a Map of cache entries, stored in a ref inside a context provider. Each entry tracks its data, error state, last update time, active subscribers, and tags.

type CacheEntry<T = any> = {
  key: string;
  data?: T;
  error?: unknown;
  updatedAt: number;
  promise?: Promise<T>;
  subscribers: number;
  tags: Set<string>;
};

The promise field is the key to request deduplication. If a fetch is already in flight for a key, any new subscriber gets the same promise instead of firing a duplicate request.

Tag-based invalidation

This is the part that clicked for me. Each query can declare tags, and you can invalidate all queries matching a tag at once.

const invalidateTags = (tags: string[]) => {
  const tagSet = new Set(tags);
  for (const [, entry] of cacheRef.current) {
    for (const t of entry.tags) {
      if (tagSet.has(t)) {
        entry.updatedAt = 0;
        break;
      }
    }
  }
};

Setting updatedAt to 0 marks the entry as stale. The next time a subscribed component checks, it refetches. Simple.

The useQuery hook

The hook subscribes to the cache on mount, triggers a fetch if the data is stale, and polls an invalidation marker to know when to re-render.

const entry = ctx.subscribe(key, tags);
 
const shouldFetch =
  !entry.updatedAt || Date.now() - entry.updatedAt > staleTime;
 
if (shouldFetch && !entry.promise) {
  ctx.fetchIfStale(key, fetcher);
}

The return value gives you everything you need:

return {
  data: entry.data,
  error: entry.error,
  isLoading: !!entry.promise && entry.data === undefined,
  isStale: !entry.updatedAt || Date.now() - entry.updatedAt > staleTime,
};

Cleanup and garbage collection

When a component unmounts, it unsubscribes. If no subscribers remain and the entry is older than 5 minutes, it gets garbage collected from the cache.

const unsubscribe = (key: string) => {
  const entry = cacheRef.current.get(key);
  if (!entry) return;
  entry.subscribers = Math.max(0, entry.subscribers - 1);
  if (entry.subscribers === 0 && Date.now() - entry.updatedAt > 1000 * 60 * 5) {
    cacheRef.current.delete(key);
  }
};

What I learned

  • Request deduplication is just storing the promise. If a fetch is in flight, return the same promise instead of starting a new one.
  • Tag-based invalidation is surprisingly simple. You just mark entries as stale and let the next subscriber trigger the refetch.
  • The polling approach for detecting invalidation (checking a marker every 100ms) works but is not ideal. TanStack Query uses listeners instead. That would be the next thing to improve.
  • staleTime is just a timestamp comparison. There is no timer or scheduler. You just compare the current time against the last update time on every access.

~200 lines to understand the core of React Query. For production, use the real thing.