Skip to content

incremental watch queries (wip) #199

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

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
"usage/use-case-examples/data-encryption",
"usage/use-case-examples/full-text-search",
"usage/use-case-examples/infinite-scrolling",
"usage/use-case-examples/watch-queries",
"usage/use-case-examples/offline-only-usage",
"usage/use-case-examples/postgis",
"usage/use-case-examples/prioritized-sync",
Expand Down
1 change: 1 addition & 0 deletions usage/use-case-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The following examples are available to help you get started with specific use c
<Card title="Data Pipelines" icon="code-branch" href="/usage/use-case-examples/custom-write-checkpoints" horizontal/>
<Card title="Full-Text Search" icon="magnifying-glass" href="/usage/use-case-examples/full-text-search" horizontal/>
<Card title="Infinite Scrolling" icon="scroll" href="/usage/use-case-examples/infinite-scrolling" horizontal/>
<Card title="Live Queries / Watch Queries" icon="eye" href="/usage/use-case-examples/watch-queries" horizontal/>
<Card title="Local-only Usage" icon="laptop" href="/usage/use-case-examples/offline-only-usage" horizontal/>
<Card title="PostGIS" icon="map" href="/usage/use-case-examples/postgis" horizontal/>
<Card title="Prioritized Sync" icon="star" href="/usage/use-case-examples/prioritized-sync" horizontal/>
Expand Down
346 changes: 346 additions & 0 deletions usage/use-case-examples/watch-queries.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
---
title: "Live Queries / Watch Queries"
description: "Subscribe to real-time data changes with reactive live queries"
---

Live queries, also known as watch queries, are essential for building reactive apps where the UI automatically updates when the underlying data changes. PowerSync's watch functionality allows you to subscribe to SQL query results and receive updates whenever the dependent tables are modified.

## Overview

PowerSync provides three types of watch queries. All methods internally query SQLite whenever dependent tables change, but they expose results differently:

- **`db.watch()`** - The original API that returns raw query results as new Array of objects every time when the underlying tables change
- **`db.query().watch()`** - Still query the SQLite DB under the hood on each dependant table change, but they compare the result set and only incrementally yield results if a change has been made. The latest query result is yielded as the result set.
- Basically: It's smarter than the above in that it knows if something changed
- **`db.query().differentialWatch()`** - Instead of only knowing if data changed, it tells you what changed:
- Added, removed, updated, and unchanged rows.
- Preserves object references for unchanged rows—so in frameworks like React, only changed rows actually update.

<Info>
**JavaScript Only**: The `db.query()` watch methods are currently available only in JavaScript SDKs.
</Info>

## Basic Watch Queries

Here are examples of how to use basic watch queries across different PowerSync client SDKs:

<CodeGroup>

```javascript JavaScript/Web
// Watch changes to the lists table
const abortController = new AbortController();

export const function watchLists = (onUpdate) => {
for await (const update of db.watch(
'SELECT * from lists',
[],
{ signal: abortController.signal }
)
) {
onUpdate(update);
}
}
```

```javascript React Native
// Using AsyncIterable pattern for watching data changes
async *todoIds(): AsyncIterable<string[]> {
for await (const result of db.watch(
`SELECT id FROM todos WHERE list_id = ? AND completed = ?`,
[listId, false]
)) {
yield result.rows?._array.map((r) => r.id) ?? [];
}
}

// Using callback pattern for watching data changes (SDK v1.3.3+)
todoIds(onResult: (ids: string[]) => void): void {
db.watch(
`SELECT id FROM todos WHERE list_id = ? AND completed = ?`,
[listId, false],
{
onResult: (result) => {
onResult(result.rows?._array.map((r) => r.id) ?? []);
}
}
);
}
```

```dart Flutter/Dart
import 'package:flutter/material.dart';

class TodosWidget extends StatelessWidget {
const TodosWidget({super.key});

@override
Widget build(BuildContext context) {
return StreamBuilder(
// You can watch any SQL query
stream: db.watch('SELECT * FROM todos WHERE list_id = ? ORDER BY id ASC', [listId]),
builder: (context, snapshot) {
if (snapshot.hasData) {
// TODO: implement your own UI here based on the result set
return ...;
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
}
```

```kotlin Kotlin Multiplatform
// Watch for changes to the todos table
fun watchTodos(): Flow<List<Todo>> =
database.watch(
"SELECT * FROM todos WHERE list_id = ?",
listOf(listId),
) { cursor ->
Todo(
id = cursor.getString("id"),
description = cursor.getString("description"),
completed = cursor.getBoolean("completed"),
)
}
```

```swift Swift
// Watch for changes to the lists table
func watchLists(_ callback: @escaping (_ lists: [ListContent]) -> Void ) async {
do {
// Actually consume the stream to trigger the error
for try await result in try database.watch(
sql: "SELECT * FROM lists",
parameters: [],
) { cursor in
try ListContent(
id: cursor.getString(name: "id"),
name: cursor.getString(name: "name"),
createdAt: cursor.getString(name: "created_at"),
ownerId: cursor.getString(name: "owner_id")
)
} {
// process result here
}
} catch {
print("Error in watch: \(error)")
}
}
```

```csharp C#/.NET
// Watch for changes to the lists table
await db.Watch("SELECT * FROM lists", null, new WatchHandler<ListResult>
{
OnResult = (results) =>
{
Console.WriteLine("Results: ");
foreach (var result in results)

Check warning on line 140 in usage/use-case-examples/watch-queries.mdx

View check run for this annotation

Mintlify / Mintlify Validation - vale-spellcheck

usage/use-case-examples/watch-queries.mdx#L140

Did you really mean 'foreach'?
{
Console.WriteLine(result.id + ":" + result.name);
}
},
OnError = (error) =>
{
Console.WriteLine("Error: " + error.Message);
}
});
```

</CodeGroup>


## Incremental Watch Queries

Basic watch queries can cause performance issues in UI frameworks like React because they return new arrays and objects on every dependent table change, even when the actual data hasn't changed. This can lead to excessive re-renders as components receive new props unnecessarily.

Incremental watch queries address this problem by handling result sets in smarter ways - comparing result sets to detect actual changes, only emitting updates when data has actually changed, preserving object references when data is unchanged, and providing granular update information to minimize unnecessary re-renders.


<Info>
**JavaScript Only**: Incremental watch queries are currently available only in the JavaScript SDKs.
</Info>

### The WatchedQuery Class

All incremental queries use the new `WatchedQuery` class, which provides advanced features:

- Automatically reprocesses itself if the PowerSync schema has been updated with `updateSchema`.
- Automatically closes itself when the PowerSync client has been closed.
- Allows for the query parameters to be updated after instantiation.
- Allows shared listening to state changes.


### 1. Comparison Watch Queries

Comparison based queries behave similar to basic watched queries. These queries still query the SQLite DB under the hood on each dependant table change, but they compare the result set and only incrementally yield results if a change has been made. The latest query result is yielded as the result set.

`db.query().watch()` method

```javascript
// Comparison-based watch query - exits early when change detected
const query = db.query({
sql: 'SELECT * FROM todos WHERE completed = ?',
parameters: [false]
}).watch();

// The registerListener method can be used multiple times to listen for updates
const dispose = query.registerListener({
onData: (data) => {
// This callback will be called whenever the data changes
console.log('Data updated:', data);
},
onStateChange: (state) => {
// This callback will be called whenever the state changes
// The state contains metadata about the query, such as isFetching, isLoading, etc.
console.log('State changed:', state.error, state.isFetching, state.isLoading, state.data);
},
onError: (error) => {
// This callback will be called if the query fails
console.error('Query error:', error);
}
});
```

### 2. Differential Watch Queries

Differential queries watch a SQL query and report detailed information on the changes between result sets. This gives additional information such as the added, removed, updated rows between result set changes.

`db.query().differentialWatch()` method

```javascript
// Create an instance of a WatchedQuery
const listsQuery = db
.query({
sql: `
SELECT
lists.*,
COUNT(todos.id) AS total_tasks,
SUM(
CASE
WHEN todos.completed = true THEN 1
ELSE 0
END
) as completed_tasks
FROM
lists
LEFT JOIN todos ON lists.id = todos.list_id
GROUP BY
lists.id;
`
})
.differentialWatch();

// The registerListener method can be used multiple times to listen for updates
// The returned dispose function can be used to unsubscribe from the updates
const disposeSubscriber = listsQuery.registerListener({
onData: (data) => {
// This callback will be called whenever the data changes
// The data is the result of the executor
console.log('Data updated:', data);
},
onStateChange: (state) => {
// This callback will be called whenever the state changes
// The state contains metadata about the query, such as isFetching, isLoading, etc.
console.log(
'State changed:',
state.error,
state.isFetching,
state.isLoading,
state.data
);
},
onError: (error) => {
// This callback will be called if the query fails
console.error('Query error:', error);
}
});
```

### Advanced Features

**Custom Differentiator Logic:**

The incremental logic is customizable with custom diff logic:

```javascript
const watch = db
.query({
sql: 'SELECT * FROM todos',
mapper: (raw) => ({
id: raw.id as string,
description: raw.description as string
})
})
.differentialWatch({
differentiator: {
identify: (item) => item.id,
compareBy: (item) => JSON.stringify(item)
}
});
```

### Query Sharing

`WatchedQuery` instances can be shared across components:

```javascript
// Create a shared query instance
const sharedTodosQuery = db
.query({ sql: 'SELECT * FROM todos WHERE list_id = ?', parameters: [listId] })
.watch();

// Multiple components can listen to the same query
const dispose1 = sharedTodosQuery.registerListener({
onData: (data) => updateTodosList(data)
});

const dispose2 = sharedTodosQuery.registerListener({
onData: (data) => updateTodosCount(data.length)
});
```

### Dynamic Parameter Updates

Update query parameters to affect all subscribers:

```javascript
// Updates to query parameters can be performed in a single place, affecting all subscribers
watch.updateSettings({
query: new GetAllQuery({ sql: `SELECT * FROM todos OFFSET ? LIMIT 100`, parameters: [newOffset] })
});
```

### React Integration

Use with React hooks:

```javascript
import { useQuery } from '@powersync/react';

function TodosList() {
const { data: todos, isLoading, error } = useQuery(
'SELECT * FROM todos WHERE completed = ?',
[false]
);

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;

return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
);
}
```

### Performance

Incremental watch queries can improve rendering performance:
- benchmarks show that incremental updates with differential queries coupled with React memoization render 60-80% faster
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The figures here were for a specific test case of rendering a single list widget. I suspect these values could vary substantially based off the actual React Widget tree being re-rendered. It might be good to mention this as a rough guideline.

- incremental updates only render the newly added item widgets, while the standard query methods re-render the entire widget
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only if Memoization is used for the component. A big benefit of incremental queries is easy integration with tools like React's Memoization.