-
-
Notifications
You must be signed in to change notification settings - Fork 684
feat: useSuspendingLiveQuery #2205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Example vibe coded app which uses this hook: https://stackblitz.com/~/github.com/phaux/rrdex Notice how if you replace this hook with regular useLiveQuery then an empty/not found state will appear briefly sometimes on navigation and also entry animations will play even though they shouldn't on first render (they should only play when adding new element to a list). If you also remove remountOnParamsChange in main then an exit animation followed by an entry animation plays for all elements in a list on every navigation, which looks cool but is not desired in this case. I used my own list animation component in this example, but the problem also appears when using other FLIP animations libraries or React's experimental ViewTransition component. |
|
Thank you for this great piece of code! I looked through the code and it looks really thought-through, intelligent and clear. I will defenitely merge this for a new official release of dexie-react-hooks. Need some small things fixed first though:
|
9307bbe to
c3f501f
Compare
|
Sorry but I don't know how to run tests in this repo. I only get I did everything except "optimization for synchronous observables" because I don't think it's necessary? A BehaviorSubject from rxjs will already emit synchronously and the next callback will run instantly so there will be no suspense. Now I want to add some tests but I'm gonna need help with that. |
|
I see. You need to have pnpm installed and then If we need to discuss more please join discord server https://discord.gg/huhre7MHBF |
|
Running tests in root fails with Building Building |
Oh, y-dexie is a new dev-dependency. Need to pnpm install and build it too (addons/y-dexie). But with pnpm in the root having done I just cloned the repo on a fresh location to verify. Here are my commands just now that at least work on my macbook with pnpm 9.8.0 installed. git clone https://github.com/dexie/Dexie.js dexie4
cd dexie4
pnpm install
pnpm build
pnpm test
cd addons/y-dexie
pnpm install
pnpm build
cd ../../libs/dexie-react-hooks/
pnpm install
pnpm build
pnpm test |
|
I added some tests. I simply modified the test app to use the suspending hook in one place and added additional steps and checks. It would be nice to also check if it works with Also a check if the caches are cleared after a query is no longer in use would make the tests better. I tried to do these but that's not so easy. Maybe I will try again to come up with something later. |
|
I added support for synchronous observables and for providing the observable itself instead of a function returning an observable. Maybe not super useful for suspense case, but in case people would prefer useSuspenseObservable() to ensure not getting undefined back for these cases, it's nice that it works equally well. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks 👍
|
@phaux Thanks for this PR! I'm very much looking forward to using Dexie with Suspense. I'm trying out this PR and ran into an issue. A call to Looking at the code. The dependencies passed to const category = useSuspendingLiveQuery(() => db.categories.get(categoryId), [categoryId])
const products = useSuspendingLiveQuery(() => db.products.where({ categoryId }).toArray(), [categoryId])Here Update: Looking at the example app, a string is given as the first dependency to |
Yes, that could be a real issue. Maybe the unique string could be enforced to be used as the first parameter, before the callback. That might make it harder to miss it? |
export const useGetCathegoryById = (categoryId: string) => useSuspendingLiveQuery(
"useGetCathegoryById",
() => db.categories.get(categoryId),
[categoryId]
) |
|
It's unfortunate there's no way to handle this internally so I played around with As a "quick fix" for now I'm doing |
|
Maybe a better API would be something like useSuspendingObservable(db, "tableName", (table) => table.get(itemId), [itemId])So the user is forced to give the table name as string and it will be used in the key automatically. |
|
@phaux I don't know if the table name is unique enough. For example. const productsWithCategory = useSuspendingLiveQuery(db, "products", (table) => table.where({ categoryId }).toArray(), [categoryId])
const productsWithTag = useSuspendingLiveQuery(db, "products", (table) => table.where({ tagId }).toArray(), [tagId])If both |
WalkthroughThe pull request upgrades dexie-react-hooks to support React 19, introduces two new React hooks for suspending components until observable values are available (useSuspendingLiveQuery and useSuspendingObservable), adds TypeScript type declarations for y-dexie and yjs, implements a usePromise utility, and updates test infrastructure to React 18 APIs. Changes
Sequence Diagram(s)sequenceDiagram
participant Component
participant useSuspendingObservable
participant Observable
participant Cache
participant usePromise
participant React
Component->>useSuspendingObservable: Call hook with observable
useSuspendingObservable->>Cache: Check cached value
alt Value cached
Cache-->>useSuspendingObservable: Return cached value
useSuspendingObservable-->>Component: Return value (sync)
else Value not cached
useSuspendingObservable->>Observable: Subscribe to observable
Observable->>Cache: Emit initial/next value
useSuspendingObservable->>usePromise: Wrap pending promise
usePromise->>React: Throw promise (Suspend)
React->>Component: Pause rendering
Observable-->>useSuspendingObservable: Value emitted
usePromise-->>React: Promise resolves
React->>Component: Resume & rerender
useSuspendingObservable-->>Component: Return latest value
end
Component->>Component: Render with value
rect rgba(100, 150, 200, 0.1)
Note over useSuspendingObservable: On unmount: unsubscribe & schedule cache cleanup
end
sequenceDiagram
participant Component as Component<br/>(using useSuspendingLiveQuery)
participant useSuspendingLiveQuery
participant Dexie
participant useSuspendingObservable
Component->>useSuspendingLiveQuery: Call with querier fn & cacheKey
useSuspendingLiveQuery->>Dexie: liveQuery(querier)
Dexie-->>useSuspendingLiveQuery: Observable of query results
useSuspendingLiveQuery->>useSuspendingObservable: Pass observable + prefixed cacheKey
useSuspendingObservable-->>useSuspendingLiveQuery: Suspends until value
useSuspendingLiveQuery-->>Component: Returns T (or throws/suspends)
rect rgba(100, 200, 100, 0.1)
Note over Component: Component suspends while data loads
Note over Dexie: Dexie query executes reactively
Note over useSuspendingObservable: Cache reused across mount cycles
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Areas requiring extra attention:
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (13)
addons/y-dexie/package.json(1 hunks)libs/dexie-react-hooks/package.json(2 hunks)libs/dexie-react-hooks/src/dexie-react-hooks.ts(1 hunks)libs/dexie-react-hooks/src/types/y-dexie.d.ts(1 hunks)libs/dexie-react-hooks/src/types/yjs.d.ts(1 hunks)libs/dexie-react-hooks/src/useObservable.ts(1 hunks)libs/dexie-react-hooks/src/usePromise.ts(1 hunks)libs/dexie-react-hooks/src/useSuspendingLiveQuery.ts(1 hunks)libs/dexie-react-hooks/src/useSuspendingObservable.ts(1 hunks)libs/dexie-react-hooks/test/components/ErrorBoundrary.tsx(2 hunks)libs/dexie-react-hooks/test/components/ItemLoaderComponent.tsx(2 hunks)libs/dexie-react-hooks/test/index.ts(5 hunks)libs/dexie-react-hooks/tsconfig.json(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
libs/dexie-react-hooks/src/useSuspendingObservable.ts (2)
libs/dexie-react-hooks/src/useObservable.ts (2)
InteropableObservable(2-9)AnySubscription(11-11)libs/dexie-react-hooks/src/usePromise.ts (1)
usePromise(4-5)
libs/dexie-react-hooks/src/useSuspendingLiveQuery.ts (1)
libs/dexie-react-hooks/src/useSuspendingObservable.ts (1)
useSuspendingObservable(12-127)
libs/dexie-react-hooks/src/types/y-dexie.d.ts (1)
addons/y-dexie/src/y-dexie.ts (1)
yDexie(16-30)
libs/dexie-react-hooks/test/index.ts (3)
libs/dexie-react-hooks/test/components/App.tsx (1)
App(7-36)libs/dexie-react-hooks/test/utils/waitTilEqual.ts (1)
waitTilEqual(3-24)libs/dexie-react-hooks/test/db/index.ts (1)
db(15-15)
libs/dexie-react-hooks/test/components/ItemLoaderComponent.tsx (1)
libs/dexie-react-hooks/src/useSuspendingLiveQuery.ts (1)
useSuspendingLiveQuery(9-17)
🪛 Biome (2.1.2)
libs/dexie-react-hooks/src/useSuspendingObservable.ts
[error] 100-100: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.
For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🔇 Additional comments (11)
addons/y-dexie/package.json (1)
11-11: LGTM – More explicit CommonJS exclusion.Changing
requirefromfalsetonullis a clearer signal that CommonJS imports are not supported for this ESM-only package.libs/dexie-react-hooks/src/types/yjs.d.ts (1)
1-7: LGTM – Minimal ambient declaration.This lightweight ambient declaration provides the necessary typing for the
yjsmodule without requiring the full type definitions as a dependency.libs/dexie-react-hooks/package.json (2)
3-3: LGTM – Alpha version for pre-release testing.The version bump to
4.2.1-alpha.1correctly signals this is a pre-release version.
66-72: LGTM – React 19 upgrade aligns with PR objectives.The upgrade to React 19 and corresponding type definitions is consistent with the PR's goal to add React 19 support and new suspending hooks.
libs/dexie-react-hooks/src/usePromise.ts (3)
1-5: LGTM – Graceful React 19 fallback.The hook correctly uses
React.usewhen available (React 19+) and falls back to a custom implementation for earlier versions.
34-37: LGTM – WeakMap prevents memory leaks.Using a WeakMap ensures promise state is garbage-collected when the promise is no longer referenced.
8-32: Implementation correctly uses React's Suspense pattern for promise settlement.The fallback implementation is correct. React's Suspense automatically re-renders the component when the thrown promise settles. On the subsequent render,
fallbackUsePromiseretrieves the settled state from the WeakMap and returns the value or throws the reason as expected.The async state updates in the
.then()callbacks (lines 14-19) update the WeakMap cache; React's Suspense mechanism, not a state update, triggers the re-render. This is the standard pattern for Suspense-based promise handling.libs/dexie-react-hooks/src/types/y-dexie.d.ts (1)
1-28: LGTM – Minimal ambient declarations for type resolution.This ambient declaration provides sufficient typing for the
y-dexiemodule. The use ofanyforYUpdateRowandYSyncState(lines 22-23) is acceptable for minimal ambient declarations where the full type definitions are not needed.Note: The comment mentions this is a "quickfix" for ESM/CJS resolution issues. Consider documenting this approach in case future improvements are needed.
libs/dexie-react-hooks/src/dexie-react-hooks.ts (1)
4-6: LGTM – New suspending hooks exposed in public API.The new exports for
useSuspendingLiveQueryanduseSuspendingObservablecorrectly expose the main features of this PR.libs/dexie-react-hooks/src/useObservable.ts (1)
6-12: LGTM – Type alias improves readability.Extracting the subscription return type into
AnySubscriptionimproves code clarity and makes the subscription pattern more explicit. The type correctly supports both RxJS-style subscriptions (object withunsubscribe()) and function-style subscriptions.libs/dexie-react-hooks/tsconfig.json (1)
3-8: Verify breaking changes and browser compatibility with library's stated support policy.The tsconfig.json now explicitly targets ES2022 with lib set to es2023. ES2022 requires Chrome 94+, Edge 94+, Firefox 93+, and Safari 16.4+. This change should be:
- Communicated as a breaking change if the library previously supported older browsers
- Verified against any stated browser support policy (not found in package.json or README)
- Documented in release notes or CHANGELOG (no CHANGELOG entry found)
Note: The prior tsconfig only inherited from a non-existent base config, so the exact previous target cannot be confirmed.
| if (!VALUES.has(observable)) { | ||
| usePromise(promise); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Call usePromise consistently to avoid breaking hook order.
On React 19, usePromise resolves to React.use, so it must be called every render in the same order. The current conditional means the first render without a cached value calls the hook, but once VALUES is populated subsequent renders skip it, triggering React’s “rendered fewer hooks than expected” error. Please invoke usePromise(promise) unconditionally so the hook order stays stable while still relying on the resolved promise to short‑circuit suspense.
- if (!VALUES.has(observable)) {
- usePromise(promise);
- }
+ usePromise(promise);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!VALUES.has(observable)) { | |
| usePromise(promise); | |
| } | |
| usePromise(promise); |
🧰 Tools
🪛 Biome (2.1.2)
[error] 100-100: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.
For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🤖 Prompt for AI Agents
In libs/dexie-react-hooks/src/useSuspendingObservable.ts around lines 99-101,
the call to usePromise is currently wrapped in a conditional which causes hook
order to change across renders; call usePromise(promise) unconditionally every
render (remove the if) so the hook runs in the same order, while still relying
on VALUES to determine whether to use the resolved value or short-circuit
suspense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚠️ Potential issue | 🔴 CriticalCall
usePromiseconsistently to avoid breaking hook order.On React 19,
usePromiseresolves toReact.use, so it must be called every render in the same order. The current conditional means the first render without a cached value calls the hook, but onceVALUESis populated subsequent renders skip it, triggering React’s “rendered fewer hooks than expected” error. Please invokeusePromise(promise)unconditionally so the hook order stays stable while still relying on the resolved promise to short‑circuit suspense.- if (!VALUES.has(observable)) { - usePromise(promise); - } + usePromise(promise);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.🧰 Tools
🪛 Biome (2.1.2)
[error] 100-100: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order. See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
(lint/correctness/useHookAtTopLevel)
🤖 Prompt for AI Agents
Not true. React use is not a hook, it's allowed in conditional paths.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
React.use conditional calls rules of hooks
💡 Result:
Short answer
- Don’t call Hooks conditionally — always call them at the top level of a function component or a custom Hook. React relies on the call order to associate Hook state across renders. [1][2]
Why
- If you call a Hook inside a condition/loop/nested fn, the number and order of Hook calls can change between renders, which breaks React’s internal state mapping and causes bugs or the “Hooks can only be called…” error. [1][2]
How to handle conditional behavior
- Keep Hook calls unconditional, put the condition inside the Hook callback:
- Bad: if (cond) useEffect(...);
- Good: useEffect(() => { if (!cond) return; /* do work */ }, [cond]); [1]
- Or wrap logic in a custom Hook that always runs (but internally short-circuits). [1]
- Use the official linter (eslint-plugin-react-hooks) to catch mistakes. [1][3]
References
- React Rules of Hooks. [1]
- Invalid Hook Call / Rules of Hooks guide. [2]
- Example explanation / community answers. [3]
🌐 Web query:
React.use API can be called conditionally
💡 Result:
No — you must not call React Hooks (e.g. useState, useEffect, custom hooks) conditionally. Hooks must be called unconditionally at the top level of a function component or custom hook so they run in the same order on every render; otherwise React can’t match hook calls to stored state and you get bugs or the invalid-hook-call error. Use a conditional inside the hook (e.g. inside useEffect/useMemo) or render different components / custom hooks instead. [1][2]
Sources:
[1] React — Rules of Hooks.
[2] React (legacy docs) — Rules of Hooks.
🌐 Web query:
React.use API React 19 documentation
💡 Result:
React 19 introduced the new React.use API (commonly written import { use } from "react") — a render-time API that can synchronously read:
- a Thenable/Promise (causes the component to suspend until it resolves) and
- a React Context value (an alternative to useContext).
Unlike ordinary hooks, use may be called conditionally or in loops (but must be called from a component or a hook) and is designed to integrate with Suspense and streaming/server features. Note: promises created directly in render are not supported — use must consume Suspense-compatible/cached resources or promises provided by a Suspense-aware library/framework. [1][2][3]
References:
- React 19 blog & feature overview. [1]
- React.use coverage in community docs/articles (examples, caveats on promises-in-render). [2][3]
@dfahlander You're absolutely correct, and I apologize for the incorrect review comment. React.use is a special API introduced in React 19 that, unlike traditional hooks, can be called conditionally or in loops. The conditional call to usePromise (which wraps React.use) is perfectly valid here.
The Biome linter warning appears to be a false positive—it's treating usePromise as a traditional hook when React.use is explicitly designed to be more flexible. The conditional pattern if (!VALUES.has(observable)) { usePromise(promise); } is correct and leverages the fact that React.use doesn't follow the standard Rules of Hooks.
Thank you for the correction!
✏️ Learnings added
Learnt from: dfahlander
Repo: dexie/Dexie.js PR: 2205
File: libs/dexie-react-hooks/src/useSuspendingObservable.ts:99-101
Timestamp: 2025-11-01T15:56:12.570Z
Learning: In React 19, React.use is a special render-time API that can be called conditionally or in loops, unlike traditional hooks (useState, useEffect, etc.). It's designed to read Promises/Thenables and Context values, and does not need to follow the Rules of Hooks regarding unconditional calls at the top level.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
|
I pubilshed this version of dexie-react-hooks as [email protected] so it's easier for anyone to try the current API out. NOTE: The API might change in future versions |
|
I've been using this hook for some time and it works pretty well. I actually ended up using just this hook modified to work on top of vanilla IndexedDB+BroadcastChannel (without Dexie). I might publish it as a separate library in the future. I was planning to push some more tests but I couldn't make them work. I can always submit what I have as a separate WIP PR if you want to merge this as is @dfahlander . |
I think it's fine as it is. I'll merge it to master once I get some feedback from people trying the the published alpha version of dexie-react-hooks (mainly for regression testing as we've updated tsconfig etc). I've also laborated with an alternative API for suspense by letting a hook provide a "synchronous" API to dexie. I'll see what comes out of all this, but it's very nice that we have an npm-published version of this for people to try out! Thanks a lot for all your effort and for a really clean and nice implementation! |
| resolve(val); | ||
| VALUES.set(observable, val); | ||
| if (!sub) calledSynchronously = true; | ||
| else unsub(sub); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@phaux @dfahlander I've noticed an issue with useSuspendingLiveQuery where the querier function is executed twice even though the dependencies and underlying data doesn't change.
Upon further investigation, calling subscribe on an observable appears to re-executes the query. Is this correct? I'm surprised the observable doesn't return the existing value when re-subscribing.
Since we subscribe both here and in the useEffect block below this is causing the querier to execute twice, once for each subscribe call.
I want to verify my assumptions here before creating an issue for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That seems correct. it's something that could be improved.
Implemented suspending hook similar to #1276 (comment)
Right now it never clears the caches. I couldn't think of a simple way to implement it.I had in mind the latest changes in Observable spec which say that observables are always hot (shared) and there is no way to detect new subscriber from observable side. This means that it's impossible to implement a BehaviorSubject which emits last value for each new subscriber. That's why I'm keeping the last value in memory for each observable. Even this is not perfect because if you pass an observable to the hook and also use it somewhere else then the first value could be missed.
I will upload an example app later which uses this so you can test it.
Summary by CodeRabbit
New Features
Chores