Skip to content

fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery#1588

Open
tsushanth wants to merge 1 commit into
TanStack:mainfrom
tsushanth:fix/issue-1587-defer-eager-onStoreChange
Open

fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery#1588
tsushanth wants to merge 1 commit into
TanStack:mainfrom
tsushanth:fix/issue-1587-defer-eager-onStoreChange

Conversation

@tsushanth

@tsushanth tsushanth commented Jun 12, 2026

Copy link
Copy Markdown

Summary

Closes #1587.

useLiveQuery's subscribeRef calls onStoreChange() synchronously inside the useSyncExternalStore subscribe function when the underlying collection is already ready:

// Collection may be ready and will not receive initial `subscribeChanges()`
if (collectionRef.current.status === \`ready\`) {
  versionRef.current += 1
  onStoreChange()
}

That synchronous notification lands during the render-to-commit window when subscribe runs under StrictMode double-render or cold/throttled loads, which React surfaces as:

Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead.

The reporter observed this consistently in a Lighthouse-cold-desktop run with React 19 + @tanstack/react-db@0.1.86 + @tanstack/db@0.6.8.

Fix

Defer the eager notification to a microtask so it lands after the current commit:

-      if (collectionRef.current.status === \`ready\`) {
-        versionRef.current += 1
-        onStoreChange()
-      }
+      if (collectionRef.current.status === \`ready\`) {
+        queueMicrotask(() => {
+          if (unsubscribed) return
+          versionRef.current += 1
+          onStoreChange()
+        })
+      }

While doing so, also guard the late-notify paths against an in-flight subscribeChanges callback firing after React unsubscribes. Track a local unsubscribed flag set by the cleanup, and drop both the eager microtask and any in-flight subscription event after teardown, so React never sees a state update post-unsubscribe:

+      let unsubscribed = false
+
       const subscription = collectionRef.current.subscribeChanges(() => {
+        if (unsubscribed) return
         versionRef.current += 1
         onStoreChange()
       })
       ...
       return () => {
+        unsubscribed = true
         subscription.unsubscribe()
       }

API impact

None. The contract of useLiveQuery is preserved — an already-ready collection still notifies React once after mount, just asynchronously instead of mid-commit. Callers observe identical state transitions; the only difference is the timing of the first notification when the collection is already warm at hook-init.

Test plan

  • pnpm test in packages/react-db94/94 pass, no type errors (existing suite unchanged)
  • Coverage on useLiveQuery.ts is 96.47% lines / 95.65% branches after the change; the new microtask branch is hit by the existing tests that exercise the ready-collection path
  • The original repro is a Lighthouse-cold-desktop run with React 19 + StrictMode double-render; existing tests don't reproduce that specific race directly (it's a render-to-commit window timing condition). The microtask deferral is correct by React's useSyncExternalStore contract regardless of repro

Notes for review

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened subscription safety in live queries with guard checks to prevent notifications after unsubscription.
    • Modified state update timing for ready collection status, deferring updates through asynchronous task queuing.
    • Enhanced consistency in collection state notifications during component lifecycle transitions.

Closes TanStack#1587.

`useLiveQuery`'s `subscribeRef` calls `onStoreChange()` synchronously
inside the `useSyncExternalStore` subscribe function when the
underlying collection is already `ready`. That synchronous notification
lands during the render-to-commit window when subscribe runs under
StrictMode double-render or cold/throttled loads, which React surfaces
as:

  Can't perform a React state update on a component that hasn't
  mounted yet. This indicates that you have a side-effect in your
  render function that asynchronously tries to update the component.
  Move this work to useEffect instead.

The fix is to defer the eager notification to a microtask so it lands
after the current commit. While doing so, also guard the late notify
path against an in-flight `subscribeChanges` callback firing after
React unsubscribes — track a local `unsubscribed` flag and drop both
the eager microtask and any in-flight subscription event after teardown,
so React never sees a state update post-unsubscribe.

No public API change; the contract of `useLiveQuery` is preserved (an
already-ready collection still notifies React once after mount, just
asynchronously instead of mid-commit).

Verified `pnpm test` in packages/react-db — 94/94 pass, no type
errors. Existing tests don't cover the race directly (it's a
StrictMode-double-render / cold-load condition observed via Lighthouse
in the issue), so the existing suite is the regression guard for
existing behavior and the issue's repro is the behavioral validation.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bef25cdc-9482-4db5-9ed9-0ed0e193f904

📥 Commits

Reviewing files that changed from the base of the PR and between 4d1abde and ba3dfc6.

📒 Files selected for processing (1)
  • packages/react-db/src/useLiveQuery.ts

📝 Walkthrough

Walkthrough

useLiveQuery's subscription initialization now defers the ready state notification from synchronous to microtask when a collection is already ready, preventing React pre-mount state update warnings. An unsubscribed flag guards against notifications arriving after unsubscribe.

Changes

Subscription race condition fix

Layer / File(s) Summary
Defer ready state notification and add unsubscribe guard
packages/react-db/src/useLiveQuery.ts
The subscribeRef callback adds an unsubscribed flag to prevent post-unsubscribe updates. When collection status is already ready, the initial version bump and onStoreChange() notification move from synchronous execution to queueMicrotask, with a re-check of the unsubscribed flag inside the deferred callback.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A subscription unwound at the right time,
No updates before commit—oh sublime!
With microtasks deferred and guards in place,
React mounts clean with no pre-commit trace! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: deferring an eager onStoreChange call to a microtask in useLiveQuery, which aligns perfectly with the changeset.
Description check ✅ Passed The PR description thoroughly covers the problem, root cause, solution with code examples, API impact, test results, and reviewer notes. It addresses the template's requirements with comprehensive context.
Linked Issues check ✅ Passed The PR fully addresses issue #1587's requirements by deferring the synchronous onStoreChange notification to a microtask and adding an unsubscribed flag guard, preventing pre-mount React state updates.
Out of Scope Changes check ✅ Passed All changes in useLiveQuery.ts are directly scoped to fixing the pre-mount state update issue identified in #1587; no unrelated modifications are present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

react-db useLiveQuery can notify React before mount when collection is already ready

1 participant