Skip to content

Commit 2b0466f

Browse files
Chriztiaanbenitav
andauthored
feature: added useSuspenseQuery hook to react package (#353)
Co-authored-by: benitav <[email protected]>
1 parent 9f3cffd commit 2b0466f

12 files changed

+19274
-23330
lines changed

.changeset/tall-mails-camp.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/react': minor
3+
---
4+
5+
Added `useSuspenseQuery` hook, allowing queries to suspend instead of returning `isLoading`/`isFetching` state.

packages/react/README.md

+161-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
# React components for PowerSync
1+
# React Hooks for PowerSync
22

3-
## Context
3+
The `powersync/react` package provides React hooks for use with the JavaScript Web SDK or React Native SDK. These hooks are designed to support reactivity, and can be used to automatically re-render React components when query results update or to access PowerSync connectivity status changes.
4+
5+
## Usage
6+
7+
### Context
48

59
Configure a PowerSync DB connection and add it to a context provider.
610

@@ -44,7 +48,7 @@ export const TodoListDisplay = () => {
4448
}
4549
```
4650

47-
### Accessing PowerSync Status
51+
## Accessing PowerSync Status
4852

4953
The provided PowerSync client status is available with the `useStatus` hook.
5054

@@ -63,9 +67,9 @@ const Component = () => {
6367
};
6468
```
6569

66-
### Queries
70+
## Reactive Queries
6771

68-
Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag. You are also able to use a compilable query (e.g. [Kysely queries](https://github.com/powersync-ja/powersync-js/tree/main/packages/kysely-driver)) as a query argument in place of a SQL statement string.
72+
The `useQuery` hook allows you to access the results of a watched query. Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag. You are also able to use a compilable query (e.g. [Kysely queries](https://github.com/powersync-ja/powersync-js/tree/main/packages/kysely-driver)) as a query argument in place of a SQL statement string.
6973

7074
```JSX
7175
// TodoListDisplay.jsx
@@ -82,7 +86,7 @@ export const TodoListDisplay = () => {
8286
}
8387
```
8488

85-
#### Query Loading
89+
### Query Loading
8690

8791
The response from `useQuery` includes the `isLoading` and `isFetching` properties, which indicate the current state of data retrieval. This can be used to show loading spinners or conditional widgets.
8892

@@ -116,3 +120,154 @@ export const TodoListsDisplayDemo = () => {
116120
};
117121

118122
```
123+
124+
### Suspense
125+
126+
The `useSuspenseQuery` hook also allows you to access the results of a watched query, but its loading and fetching states are handled through [Suspense](https://react.dev/reference/react/Suspense). Unlike `useQuery`, the hook doesn't return `isLoading` or `isFetching` for the loading states nor `error` for the error state. These should be handled with variants of `<Suspense>` and `<ErrorBoundary>` respectively.
127+
128+
```JSX
129+
// TodoListDisplaySuspense.jsx
130+
import { ErrorBoundary } from 'react-error-boundary';
131+
import { Suspense } from 'react';
132+
import { useSuspenseQuery } from '@powersync/react';
133+
134+
const TodoListContent = () => {
135+
const { data: todoLists } = useSuspenseQuery("SELECT * FROM lists");
136+
137+
return (
138+
<ul>
139+
{todoLists.map((list) => (
140+
<li key={list.id}>{list.name}</li>
141+
))}
142+
</ul>
143+
);
144+
};
145+
146+
147+
export const TodoListDisplaySuspense = () => {
148+
return (
149+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
150+
<Suspense fallback={<div>Loading todo lists...</div>}>
151+
<TodoListContent />
152+
</Suspense>
153+
</ErrorBoundary>
154+
);
155+
};
156+
```
157+
158+
#### Blocking navigation on Suspense
159+
160+
When you provide a Suspense fallback, suspending components will cause the fallback to render. Alternatively, React's [startTransition](https://react.dev/reference/react/startTransition) allows navigation to be blocked until the suspending components have completed, preventing the fallback from displaying. This behavior can be facilitated by your router — for example, react-router supports this with its [startTransition flag](https://reactrouter.com/en/main/upgrading/future#v7_starttransition).
161+
162+
> Note: In this example, the `<Suspense>` boundary is intentionally omitted to delegate the handling of the suspending state to the router.
163+
164+
```JSX
165+
// routerAndLists.jsx
166+
import { RouterProvider } from 'react-router-dom';
167+
import { ErrorBoundary } from 'react-error-boundary';
168+
import { useSuspenseQuery } from '@powersync/react';
169+
170+
export const Index() {
171+
return <RouterProvider router={router} future={{v7_startTransition: true}} />
172+
}
173+
174+
const TodoListContent = () => {
175+
const { data: todoLists } = useSuspenseQuery("SELECT * FROM lists");
176+
177+
return (
178+
<ul>
179+
{todoLists.map((list) => (
180+
<li key={list.id}>{list.name}</li>
181+
))}
182+
</ul>
183+
);
184+
};
185+
186+
187+
export const TodoListsPage = () => {
188+
return (
189+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
190+
<TodoListContent />
191+
</ErrorBoundary>
192+
);
193+
};
194+
```
195+
196+
#### Managing Suspense When Updating `useSuspenseQuery` Parameters
197+
198+
When data in dependent tables changes, `useSuspenseQuery` automatically updates without suspending. However, changing the query parameters causes the hook to restart and enter a suspending state again, which triggers the suspense fallback. To prevent this and keep displaying the stale data until the new data is loaded, wrap the parameter changes in React's [startTransition](https://react.dev/reference/react/startTransition) or use [useDeferredValue](https://react.dev/reference/react/useDeferredValue).
199+
200+
```JSX
201+
// TodoListDisplaySuspenseTransition.jsx
202+
import { ErrorBoundary } from 'react-error-boundary';
203+
import React, { Suspense } from 'react';
204+
import { useSuspenseQuery } from '@powersync/react';
205+
206+
const TodoListContent = () => {
207+
const [query, setQuery] = React.useState('SELECT * FROM lists');
208+
const { data: todoLists } = useSuspenseQuery(query);
209+
210+
return (
211+
<div>
212+
<button
213+
onClick={() => {
214+
React.startTransition(() => setQuery('SELECT * from lists limit 1'));
215+
}}>
216+
Update
217+
</button>
218+
<ul>
219+
{todoLists.map((list) => (
220+
<li key={list.id}>{list.name}</li>
221+
))}
222+
</ul>
223+
</div>
224+
);
225+
};
226+
227+
export const TodoListDisplaySuspense = () => {
228+
return (
229+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
230+
<Suspense fallback={<div>Loading todo lists...</div>}>
231+
<TodoListContent />
232+
</Suspense>
233+
</ErrorBoundary>
234+
);
235+
};
236+
```
237+
238+
and
239+
240+
```JSX
241+
// TodoListDisplaySuspenseDeferred.jsx
242+
import { ErrorBoundary } from 'react-error-boundary';
243+
import React, { Suspense } from 'react';
244+
import { useSuspenseQuery } from '@powersync/react';
245+
246+
const TodoListContent = () => {
247+
const [query, setQuery] = React.useState('SELECT * FROM lists');
248+
const deferredQueryQuery = React.useDeferredValue(query);
249+
250+
const { data: todoLists } = useSuspenseQuery(deferredQueryQuery);
251+
252+
return (
253+
<div>
254+
<button onClick={() => setQuery('SELECT * from lists limit 1')}>Update</button>
255+
<ul>
256+
{todoLists.map((list) => (
257+
<li key={list.id}>{list.name}</li>
258+
))}
259+
</ul>
260+
</div>
261+
);
262+
};
263+
264+
export const TodoListDisplaySuspense = () => {
265+
return (
266+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
267+
<Suspense fallback={<div>Loading todo lists...</div>}>
268+
<TodoListContent />
269+
</Suspense>
270+
</ErrorBoundary>
271+
);
272+
};
273+
```

packages/react/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@
2929
},
3030
"homepage": "https://docs.powersync.com",
3131
"peerDependencies": {
32-
"react": "*",
33-
"@powersync/common": "workspace:^1.19.0"
32+
"@powersync/common": "workspace:^1.19.0",
33+
"react": "*"
3434
},
3535
"devDependencies": {
3636
"@testing-library/react": "^15.0.2",
3737
"@types/react": "^18.2.34",
3838
"jsdom": "^24.0.0",
3939
"react": "18.2.0",
40+
"react-error-boundary": "^4.1.0",
4041
"typescript": "^5.5.3"
4142
}
4243
}

packages/react/src/QueryStore.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { AbstractPowerSyncDatabase } from '@powersync/common';
2+
import { Query, WatchedQuery } from './WatchedQuery';
3+
import { AdditionalOptions } from './hooks/useQuery';
4+
5+
export function generateQueryKey(sqlStatement: string, parameters: any[], options: AdditionalOptions): string {
6+
return `${sqlStatement} -- ${JSON.stringify(parameters)} -- ${JSON.stringify(options)}`;
7+
}
8+
9+
export class QueryStore {
10+
cache = new Map<string, WatchedQuery>();
11+
12+
constructor(private db: AbstractPowerSyncDatabase) {}
13+
14+
getQuery(key: string, query: Query<unknown>, options: AdditionalOptions) {
15+
if (this.cache.has(key)) {
16+
return this.cache.get(key);
17+
}
18+
19+
const q = new WatchedQuery(this.db, query, options);
20+
const disposer = q.registerListener({
21+
disposed: () => {
22+
this.cache.delete(key);
23+
disposer?.();
24+
}
25+
});
26+
27+
this.cache.set(key, q);
28+
29+
return q;
30+
}
31+
}
32+
33+
let queryStores: WeakMap<AbstractPowerSyncDatabase, QueryStore> | undefined = undefined;
34+
35+
export function getQueryStore(db: AbstractPowerSyncDatabase): QueryStore {
36+
queryStores ||= new WeakMap();
37+
const existing = queryStores.get(db);
38+
if (existing) {
39+
return existing;
40+
}
41+
const store = new QueryStore(db);
42+
queryStores.set(db, store);
43+
return store;
44+
}

0 commit comments

Comments
 (0)