Watch queries, also known as live 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 multiple approaches to watching queries, each designed for different use cases and performance requirements:
  1. Basic Watch Queries - These queries work across all SDKs, providing real-time updates when dependent tables change
  2. Incremental Watch Queries - Only emit updates when data actually changes, preventing unnecessary re-renders
  3. Differential Watch Queries - Provide detailed information about what specifically changed between result sets
Choose the approach that best fits your platform and performance needs.

Basic Watch Queries

PowerSync supports the following basic watch queries based on your platform. These APIs return query results whenever the underlying tables change and are available across all SDKs. Scroll horizontally to find your preferred platform/framework for an example:
This method is only being maintained for backwards compatibility purposes. Use the improved db.query.watch() API instead (see Incremental Watch Queries below).
The original watch method using the AsyncIterator pattern. This is the foundational watch API that works across all JavaScript environments and is being maintained for backwards compatibility.
async function* pendingLists(): AsyncIterable<string[]> {
  for await (const result of db.watch(
    `SELECT * FROM lists WHERE state = ?`,
    ['pending']
  )) {
    yield result.rows?._array ?? [];
  }
} 

Incremental Watch Queries

Basic watch queries can cause performance issues in UI frameworks like React because they return new data on every dependent table change, even when the actual data in the query hasn’t changed. This can lead to excessive re-renders as components receive updates unnecessarily. Incremental watch queries solve this by comparing result sets using configurable comparators and only emitting updates when the comparison detects actual data changes. These queries still query the SQLite DB under the hood on each dependent table change, but compare the result sets and only yield results if a change has been made.
JavaScript Only: Incremental and differential watch queries are currently only available in the JavaScript SDKs starting from:
  • Web v1.25.0
  • React Native v1.23.1
  • Node.js v0.8.1
Basic Syntax:
db.query({ sql: 'SELECT * FROM lists WHERE state = ?', parameters: ['pending'] }).watch();
Scroll horizontally to find your preferred approach for an example:
WatchedQuery class that comes with a better API in that it includes loading, fetching and error states, supports multiple listeners, automatic cleanup on PowerSync close, and the new updateSettings() API for dynamic parameter changes. This is the preferred approach for JavaScript SDKs:
// Create an instance of a WatchedQuery
const pendingLists = db
  .query({
    sql: 'SELECT * FROM lists WHERE state = ?',
    parameters: ['pending']
  })
  .watch();

// The registerListener method can be used multiple times to listen for updates
const dispose = pendingLists.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);
  }
});

Differential Watch Queries

Differential queries go a step further than incremental watched queries by computing and reporting diffs between result sets (added/removed/updated items) while preserving object references for unchanged items. This enables more precise UI updates.
JavaScript Only: Incremental and differential watch queries are currently only available in the JavaScript SDKs starting from:
  • Web v1.25.0
  • React Native v1.23.1
  • Node.js v0.8.1
Basic syntax:
db.query({ sql: 'SELECT * FROM lists WHERE state = ?', parameters: ['pending'] }).differentialWatch();
Use differential watch when you need to know exactly which items were added, removed, or updated rather than re-processing entire result sets:
// Create an instance of a WatchedQuery
const pendingLists = db
  .query({
    sql: 'SELECT * FROM lists WHERE state = ?',
    parameters: ['pending']
  })
  .differentialWatch();

// The registerListener method can be used multiple times to listen for updates
const dispose = pendingLists.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);
  },
  onDiff: (diff) => {
    // This callback will be called whenever the data changes.
    console.log('Data updated:', diff.added, diff.updated);
  }
});
By default, the differentialWatch() method uses a DEFAULT_ROW_COMPARATOR. This comparator identifies (keys) each row by its id column if present, or otherwise by the JSON string of the entire row. For row comparison, it uses the JSON string representation of the full row. This approach is generally safe and effective for most queries. For some queries, performance could be improved by supplying a custom rowComparator. Such as comparing by a hash column generated or stored in SQLite. These hashes currently require manual implementation.
const pendingLists = db
  .query({
    sql: 'SELECT * FROM lists WHERE state = ?',
    parameters: ['pending']
  })
  .differentialWatch({
    rowComparator: {
      keyBy: (item) => item.id,
      compareBy: (item) => item._hash
    }
  });
The Yjs Document Collaboration Demo app showcases the use of differential watch queries. New document updates are passed to Yjs for consolidation as they are synced. See the implementation here for more details.

The WatchedQuery Class

Both incremental and differential queries use the new WatchedQuery class. This class, along with a new query method allows building instances of WatchedQuerys via the watch and differentialWatch methods:
const watchedQuery = db.query({ sql: 'SELECT * FROM lists', parameters: [] }).watch();
This class 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.
  • New updateSettings API for dynamic parameter updates (see below).

Query Sharing

WatchedQuery instances can be shared across components:
// 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 of the query:
// 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 Hook for External WatchedQuery Instances

When you need to share query instances across components or manage their lifecycle independently from component mounting, use the useWatchedQuerySubscription hook. This is ideal for global state management, query caching, or when multiple components need to subscribe to the same data:
// Managing the WatchedQuery externally can extend its lifecycle and allow in-memory caching between components.
const pendingLists = db
  .query({
    sql: 'SELECT * FROM lists WHERE state = ?',
    parameters: ['pending']
  })
  .watch();

// In the component
export const MyComponent = () => {
  // In React one could import the `pendingLists` query or create a context provider for various queries
  const { data } = useWatchedQuerySubscription(pendingLists);

  return (
    <div>
      {data.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};