Skip to content

refactor(vue-db): modernize useLiveQuery reactivity and lifecycle patterns#1302

Open
Danny-Devs wants to merge 1 commit intoTanStack:mainfrom
Danny-Devs:fix/vue-db-align-best-practices
Open

refactor(vue-db): modernize useLiveQuery reactivity and lifecycle patterns#1302
Danny-Devs wants to merge 1 commit intoTanStack:mainfrom
Danny-Devs:fix/vue-db-align-best-practices

Conversation

@Danny-Devs
Copy link

@Danny-Devs Danny-Devs commented Feb 26, 2026

Hi Kyle! Super stoked that you are pushing forward the Vue plugin/adapter for Tanstack DB :). I'm a fellow Vue aficionado. Hope that this PR is of high quality and helps the codebase. The description is long because I wanted to make sure to explain everything in a clear and communicative way. Looking forward to helping the Vue plugin reach parity with the React one. Lmk if I can adjust anything! Thanks. - Danny

Summary

Aligns useLiveQuery with Vue 3's recommended reactivity primitives and lifecycle
patterns. The React adapter idiomatically uses useSyncExternalStore; the Solid
adapter uses createMemo + ReactiveMap. This PR brings the Vue adapter's internals
closer to the same standard using Vue's own toolkit.

All 30 existing tests pass unchanged. 17 new tests validate each behavioral improvement.
No breaking changes to public API.

TL;DR

# What Before After Why
1 State Map reactive(new Map()) shallowReactive(new Map()) Collection items are immutable snapshots — deep proxying has O(N×M) cost with zero utility
2 Data array reactive([]) + length=0 + push(...) shallowRef([]) + .value assignment Eliminates double-notification per update and avoids deep proxying array elements
3 Disabled queries throw Error('__DISABLED_QUERY__') + catch BaseQueryBuilder probe returning null Follows the same approach as the React and Solid adapters; avoids exception-driven control flow and debugger noise
4 Lifecycle cleanup getCurrentInstance() + onUnmounted() getCurrentScope() + onScopeDispose() Works in components, composables, AND standalone effectScope() — the modern Vue 3 pattern
5 GC time Not set (library default) gcTime: 1 for hook-created collections Hook-scoped collections are ephemeral; immediate GC prevents accumulation during navigation. User-specified gcTime in config objects is preserved.
6 Collection detection Duck typing (3 property checks) instanceof CollectionImpl Authoritative identity check; precise TypeScript narrowing
7 Status timing nextTick() in onFirstReady Synchronous assignment Removes one-tick window where isLoading reads true after data is ready
8 API surface No isEnabled field Added isEnabled: ComputedRef<boolean> Completes the status predicate set alongside isLoading, isReady, etc.

What's NOT changed

  • Computed wrappers on return values — kept for API stability
  • Double-initialization (manual init + includeInitialState: true) — kept as a safety net
  • All public API signatures — no breaking changes to return types or call signatures

Test plan

  • All 30 existing tests pass unchanged (25 runtime + 5 type)
  • 17 new tests in useLiveQuery-bestpractices.test.ts covering each change above
  • Test utilities extracted to shared test-utils.ts (used by both test files)
  • Statement coverage: 83.9% → 87.35%

Detailed rationale for each change (click to expand — the TL;DR table above is self-contained if you prefer to skip this)

1. State Map: reactive(new Map())shallowReactive(new Map())

Collection items coming out of the IVM engine are immutable snapshots — they're
replaced wholesale on updates, never mutated in place. reactive() recursively wraps
every stored object in a Proxy, creating O(N×M) traps for N items with M properties.
Since no code ever mutates these objects, the deep tracking does work with zero benefit.

shallowReactive() tracks Map operations (set, delete, has, get, size)
without proxying the values stored inside. Vue's template reactivity still triggers
correctly when items are added, removed, or replaced — it just skips the unnecessary
deep observation of each item's properties.

2. Data array: reactive([])shallowRef([])

Two issues with the original approach:

First, reactive([]) deep-proxies every array element, same overhead as #1 above.

Second, the update pattern internalData.length = 0 followed by
internalData.push(...items) triggers two separate reactivity notifications per update
cycle. Any watchEffect or computed reading the array sees an intermediate empty state
between the two operations. Vue's scheduler batches these for template rendering, but
synchronous computed chains can observe the inconsistency.

shallowRef([]) with .value = newArray provides exactly-once notification semantics
via atomic reference replacement. No deep proxying of elements, no intermediate states.

3. Disabled queries: sentinel throw → BaseQueryBuilder probe

The original implementation detected disabled queries by wrapping the query function
to throw Error('__DISABLED_QUERY__') when it returned null/undefined, then catching
that specific error string.

This uses exception-driven control flow, which has a few practical costs: engines like V8
capture a full stack trace on every new Error() (allocation pressure when queries toggle
frequently), browser devtools configured to "break on caught exceptions" will halt on
every disable, and the string-matching on error.message is fragile if error-wrapping
middleware is involved.

The replacement follows the same approach used by the React and Solid adapters:
instantiate a lightweight, stateless BaseQueryBuilder, pass it to the query function,
and check the return value. If it's null or undefined, the query is disabled. No
exceptions, no string matching. Simple branching on a return value.

4. Lifecycle cleanup: getCurrentInstance() + onUnmounted()getCurrentScope() + onScopeDispose()

getCurrentInstance() only returns a value inside a Vue component's setup(). If
useLiveQuery is called inside a composable used from a standalone effectScope()
common in tests, SSR contexts, and utility libraries — getCurrentInstance() returns
null and the cleanup callback never registers. The subscription leaks.

getCurrentScope() + onScopeDispose() is the modern Vue 3 pattern that works across
all contexts: components, composables called from components, and standalone
effectScope() blocks. Every component has an effect scope, so this is strictly more
general.

The getCurrentScope() guard is still necessary because onScopeDispose() emits a dev
warning when called outside any scope (e.g., in tests that call composables directly
without wrapping in an effectScope).

5. GC time: unset → gcTime: 1 for hook-created collections

When useLiveQuery internally creates a collection via createLiveQueryCollection,
that collection is ephemeral — tied to one hook instance in one component. When the
component unmounts, the collection should be garbage-collected immediately.

Without an explicit gcTime, the library's default retention applies, meaning the
collection lingers in memory after its scope ends. For applications with frequent
mount/unmount cycles (route transitions, tab switches, virtualized lists), ephemeral
collections accumulate in the GC queue unnecessarily.

gcTime: 1 (not 0, since 0 disables GC in TanStack DB) signals immediate cleanup.
Pre-created collections passed directly to useLiveQuery are user-managed resources,
so their gcTime is preserved as-is. For the config-object path, the default gcTime
is applied first and the user's spread comes after, so user-specified gcTime takes
precedence.

6. Collection detection: duck typing → instanceof CollectionImpl

The original detection checked for the presence of subscribeChanges,
startSyncImmediate, and id properties. This structural check has a few
fragilities: if CollectionImpl renames or removes a method in a future version, the
check silently stops matching (no compile-time signal). Objects with a coincidental
shape overlap produce false positives. And the TypeScript narrowing is weaker — you
get checked-any rather than a precise type.

instanceof CollectionImpl is a single prototype-chain lookup that provides authoritative
identity checking and precise TypeScript narrowing. The standard tradeoff — instanceof
breaks across realm boundaries (iframes) — is a non-concern for a UI framework hook
running in the main thread's reactive context.

7. Status timing: nextTick() in onFirstReady → synchronous assignment

The onFirstReady callback had its status.value assignment wrapped in nextTick(),
deferring it by one microtask. This created a one-tick window where the collection was
already ready but status.value still read loading. For UI code that gates skeleton
→ content transitions on isLoading, this could produce a single frame of incorrect
rendering.

Removing the nextTick makes the status update synchronous with the collection's state
transition. Vue's reactivity system handles synchronous ref assignments correctly — no
deferral is needed here.

8. API surface: added isEnabled

The return type had boolean computeds for isLoading, isReady, isIdle, isError,
and isCleanedUp, but lacked isEnabled. Consumers of disabled queries had to write
status.value !== 'disabled' manually. Adding isEnabled completes the status
predicate set and makes the API self-documenting.

🤖 Generated with Claude Code

…terns

Align useLiveQuery with Vue 3's recommended primitives:

- Use shallowReactive(Map) instead of reactive(Map) for immutable collection items
- Use shallowRef([]) instead of reactive([]) for atomic array replacement
- Replace sentinel-throw disabled query detection with BaseQueryBuilder probe
- Use getCurrentScope/onScopeDispose instead of getCurrentInstance/onUnmounted
- Set gcTime on hook-created collections for immediate cleanup
- Use instanceof CollectionImpl instead of duck-typing for collection detection
- Remove unnecessary nextTick in onFirstReady callback
- Add isEnabled computed to return type

Also extract shared test utilities to test-utils.ts and add 17 targeted tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Feb 26, 2026

⚠️ No Changeset found

Latest commit: 9542d22

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant