Skip to content

Commit

Permalink
feature: added useSuspenseQuery hook to react package (#353)
Browse files Browse the repository at this point in the history
Co-authored-by: benitav <[email protected]>
  • Loading branch information
Chriztiaan and benitav authored Oct 21, 2024
1 parent 9f3cffd commit 2b0466f
Show file tree
Hide file tree
Showing 12 changed files with 19,274 additions and 23,330 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-mails-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/react': minor
---

Added `useSuspenseQuery` hook, allowing queries to suspend instead of returning `isLoading`/`isFetching` state.
167 changes: 161 additions & 6 deletions packages/react/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# React components for PowerSync
# React Hooks for PowerSync

## Context
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.

## Usage

### Context

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

Expand Down Expand Up @@ -44,7 +48,7 @@ export const TodoListDisplay = () => {
}
```

### Accessing PowerSync Status
## Accessing PowerSync Status

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

Expand All @@ -63,9 +67,9 @@ const Component = () => {
};
```

### Queries
## Reactive Queries

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.
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.

```JSX
// TodoListDisplay.jsx
Expand All @@ -82,7 +86,7 @@ export const TodoListDisplay = () => {
}
```

#### Query Loading
### Query Loading

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.

Expand Down Expand Up @@ -116,3 +120,154 @@ export const TodoListsDisplayDemo = () => {
};

```

### Suspense

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.

```JSX
// TodoListDisplaySuspense.jsx
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
import { useSuspenseQuery } from '@powersync/react';

const TodoListContent = () => {
const { data: todoLists } = useSuspenseQuery("SELECT * FROM lists");

return (
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
);
};


export const TodoListDisplaySuspense = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading todo lists...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
};
```

#### Blocking navigation on Suspense

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).

> Note: In this example, the `<Suspense>` boundary is intentionally omitted to delegate the handling of the suspending state to the router.
```JSX
// routerAndLists.jsx
import { RouterProvider } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseQuery } from '@powersync/react';

export const Index() {
return <RouterProvider router={router} future={{v7_startTransition: true}} />
}

const TodoListContent = () => {
const { data: todoLists } = useSuspenseQuery("SELECT * FROM lists");

return (
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
);
};


export const TodoListsPage = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<TodoListContent />
</ErrorBoundary>
);
};
```

#### Managing Suspense When Updating `useSuspenseQuery` Parameters

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).

```JSX
// TodoListDisplaySuspenseTransition.jsx
import { ErrorBoundary } from 'react-error-boundary';
import React, { Suspense } from 'react';
import { useSuspenseQuery } from '@powersync/react';

const TodoListContent = () => {
const [query, setQuery] = React.useState('SELECT * FROM lists');
const { data: todoLists } = useSuspenseQuery(query);

return (
<div>
<button
onClick={() => {
React.startTransition(() => setQuery('SELECT * from lists limit 1'));
}}>
Update
</button>
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
</div>
);
};

export const TodoListDisplaySuspense = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading todo lists...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
};
```

and

```JSX
// TodoListDisplaySuspenseDeferred.jsx
import { ErrorBoundary } from 'react-error-boundary';
import React, { Suspense } from 'react';
import { useSuspenseQuery } from '@powersync/react';

const TodoListContent = () => {
const [query, setQuery] = React.useState('SELECT * FROM lists');
const deferredQueryQuery = React.useDeferredValue(query);

const { data: todoLists } = useSuspenseQuery(deferredQueryQuery);

return (
<div>
<button onClick={() => setQuery('SELECT * from lists limit 1')}>Update</button>
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
</div>
);
};

export const TodoListDisplaySuspense = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading todo lists...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
};
```
5 changes: 3 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@
},
"homepage": "https://docs.powersync.com",
"peerDependencies": {
"react": "*",
"@powersync/common": "workspace:^1.19.0"
"@powersync/common": "workspace:^1.19.0",
"react": "*"
},
"devDependencies": {
"@testing-library/react": "^15.0.2",
"@types/react": "^18.2.34",
"jsdom": "^24.0.0",
"react": "18.2.0",
"react-error-boundary": "^4.1.0",
"typescript": "^5.5.3"
}
}
44 changes: 44 additions & 0 deletions packages/react/src/QueryStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AbstractPowerSyncDatabase } from '@powersync/common';
import { Query, WatchedQuery } from './WatchedQuery';
import { AdditionalOptions } from './hooks/useQuery';

export function generateQueryKey(sqlStatement: string, parameters: any[], options: AdditionalOptions): string {
return `${sqlStatement} -- ${JSON.stringify(parameters)} -- ${JSON.stringify(options)}`;
}

export class QueryStore {
cache = new Map<string, WatchedQuery>();

constructor(private db: AbstractPowerSyncDatabase) {}

getQuery(key: string, query: Query<unknown>, options: AdditionalOptions) {
if (this.cache.has(key)) {
return this.cache.get(key);
}

const q = new WatchedQuery(this.db, query, options);
const disposer = q.registerListener({
disposed: () => {
this.cache.delete(key);
disposer?.();
}
});

this.cache.set(key, q);

return q;
}
}

let queryStores: WeakMap<AbstractPowerSyncDatabase, QueryStore> | undefined = undefined;

export function getQueryStore(db: AbstractPowerSyncDatabase): QueryStore {
queryStores ||= new WeakMap();
const existing = queryStores.get(db);
if (existing) {
return existing;
}
const store = new QueryStore(db);
queryStores.set(db, store);
return store;
}
Loading

0 comments on commit 2b0466f

Please sign in to comment.