Skip to content

Query Instance Caching

Arc maintains an application-scoped cache of query instances. When two components subscribe to the same query with the same arguments, they share a single underlying query object rather than creating two independent instances — reducing server round-trips and keeping all consumers in sync automatically.

The cache is keyed by query type name and serialized arguments. The following rules apply:

ScenarioResult
Same type + same argumentsSame shared instance
Same type + different argumentsDifferent instances
Two components, same type + same argsOne shared instance; both receive updates simultaneously

When a new subscriber mounts, it receives the last known result immediately from the cache rather than waiting for the next server push. This means components that unmount and remount (or re-render into a new subtree) present stale-but-fast data first, then update as fresh data arrives.

When the last subscriber unmounts, the cache entry is released. The next time any component subscribes to the same query, a fresh connection is established.

Component A mounts
→ Cache miss → create new query instance → subscribe to server → receive first result
→ Cache stores the result
Component B mounts (same query, same args)
→ Cache hit → reuse existing instance
→ Immediately seeded with the last known result from the cache
→ No new server connection
Component A unmounts
→ Release reference (ref-count decrements)
→ Instance stays alive because Component B still holds a reference
Component B unmounts
→ Release reference → ref-count reaches 0 → cache entry evicted
→ Query instance unsubscribed from server

The cache is used automatically by useObservableQuery and useQuery. No changes are needed in component code — the public hook signatures are unchanged.

// These two components share one query instance and one server connection.
export const BalanceSummary = () => {
const [result] = AllAccounts.use();
return <span>Total: {result.data?.length ?? 0} accounts</span>;
};
export const AccountList = () => {
const [result] = AllAccounts.use();
return <DataTable value={result.data} />;
};

Both BalanceSummary and AccountList consume the same AllAccounts instance. When AllAccounts pushes an update, both components re-render simultaneously.

The cache is provided via QueryInstanceCacheContext, initialized once per <Arc> mount. This means the cache scope matches the application boundary — all components under <Arc> share the same cache, and there is no need to configure anything explicitly.

// The cache is initialized here, once per app.
<Arc microservice="my-app">
<MyApp />
</Arc>

The cache key includes serialized arguments, so queries with different arguments are stored as separate instances.

// These are TWO separate cache entries — different arguments.
const [accountsForAlice] = AccountsByOwner.use({ owner: 'alice' });
const [accountsForBob] = AccountsByOwner.use({ owner: 'bob' });
// This is the SAME cache entry as accountsForAlice above.
const [aliceAgain] = AccountsByOwner.use({ owner: 'alice' });

Arguments are serialized by sorting the object keys alphabetically and stringifying, so { a: 1, b: 2 } and { b: 2, a: 1 } produce the same cache key.

React 18 and later run effects twice in development — and, crucially, the same synthetic unmount/remount cycle can occur in production when React reconciles across Suspense boundaries or concurrent renders. This means a component’s useEffect cleanup may fire even when the component immediately re-mounts.

Arc handles this automatically using deferred teardown. When the last subscriber releases a cache entry, the teardown (server unsubscribe) is not called immediately. Instead, it is scheduled with setTimeout(0). If the same query re-subscribes before the timer fires — which is exactly what React’s remount cycle does — the pending teardown is cancelled and the existing connection is reused transparently.

Component mounts → acquire entry (refCount = 1)
Component unmounts → release entry (refCount = 0) → schedule deferred teardown
[setTimeout fires]
← Component remounts → acquire entry cancels teardown before timer fires
→ connection survives; last result still available

This behavior is unconditional — it applies in all environments, not just when React DevTools’ “Strict Mode” is active. The development prop on <Arc> does not change teardown timing and is retained only for API compatibility.

Relationship with the Conditional when() Hook

Section titled “Relationship with the Conditional when() Hook”

The when(condition) pattern interacts with the cache correctly: when isEnabled is false, no cache lookup or creation occurs, and no server connection is established. The hook returns QueryResultWithState.empty() without touching the cache.

// No cache entry is created until `selectedId` is truthy.
const [result] = AllProjects.when(!!selectedId).use({ id: selectedId });

Once the condition becomes true, the cache is looked up or populated on the next render.