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.
How It Works
Section titled “How It Works”The cache is keyed by query type name and serialized arguments. The following rules apply:
| Scenario | Result |
|---|---|
| Same type + same arguments | Same shared instance |
| Same type + different arguments | Different instances |
| Two components, same type + same args | One 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.
Lifecycle
Section titled “Lifecycle”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 serverTransparent Integration
Section titled “Transparent Integration”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.
Cache Context
Section titled “Cache Context”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>Parameterized Queries
Section titled “Parameterized Queries”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 StrictMode Compatibility
Section titled “React StrictMode Compatibility”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 availableThis 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.
See also
Section titled “See also”- Observable Query Multiplexing — How hub connections are managed and configured.
- Queries — General query hooks and the
when()conditional pattern. - Observable Query Hub — Server-side protocol and authorization.