DATE:
AUTHOR:
PowerSync Product Team
React Native SDK JavaScript/Web SDK Node.js SDK

Introducing Incremental and Differential Watch Queries for JavaScript

DATE:
AUTHOR: PowerSync Product Team

Overview

Today we are introducing incremental and differential watch queries for our JavaScript SDKs (Web, React Native and Node.js).

Watch queries are essential for building reactive apps where the UI automatically updates when the underlying data changes.

Our existing client SDKs currently support basic watch queries - these queries emit new results whenever data in the underlying tables updates.

While sufficient for most use cases, 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.

Why we added two new modes

We introduced incremental and differential watch queries to tackle these performance pain points:

Incremental

  • Compares the latest result set with the previous one and emits an update only if they differ.

  • Use it when you just need to know “has data changed?” and want to avoid unnecessary re‑renders.

Differential

  • Produces a structured diff (added, removed, updated, unchanged, all) so you can patch lists in place while keeping references for rows that stayed the same.

  • A common use case for this are collaboration features (e.g., Yjs) that need row‑level detail.

Both modes still run the query on every relevant table change; they simply skip sending results when nothing changed.

New WatchedQuery API

The new WatchedQuery class is the shared engine behind both modes. Create an instance of this class with .watch() or .differentialWatch() and subscribe to it from components:

// Build the WatchedQuery instance
const pendingLists = db
  .query({ sql: 'SELECT * FROM lists WHERE state = ?', parameters: ['pending'] })
  .watch({                                    // or .differentialWatch()
    comparator: {                             // optional for .watch
      checkEquality: (cur, prev) => JSON.stringify(cur) === JSON.stringify(prev)
    }
  });

// Subscribe from any module or component
const dispose = pendingLists.registerListener({
  onData: rows       => console.log(rows),
  onStateChange: st  => console.log(st.isFetching, st.error),
  onError: console.error
});

WatchedQuery closes itself when the PowerSync client closes, re‑runs after updateSchema, supports multiple listeners, and lets you adjust parameters through updateSettings(). Learn more about this class here.

React and Vue hooks

The useQuery and useSuspenseQuery hooks now leverage the same WatchedQuery core. Incremental updates via row comparison are opt‑in via the rowComparator option:

const { data } = useQuery(
  'SELECT * FROM lists WHERE state = ?',
  ['pending'],
  {
    rowComparator: {
      keyBy:    (row) => row.id,
      compareBy:(row) => JSON.stringify(row)
    }
  }
);

A new useWatchedQuerySubscription helper lets multiple components subscribe to a shared WatchedQuery instance for in‑memory caching.

Differential watch

Differential watch goes a step further than incremental queries by telling you what data changed. Instead of sending a full result set each time, it hands you a diff so you can patch local state precisely:

const todos = db
  .query({ sql: 'SELECT * FROM todos', parameters: [] })
  .differentialWatch();             // uses a safe default row comparator

todos.registerListener({
  onDiff: ({ added, updated }) => {
    /* Apply patches here */
    console.log('Data updated:', diff.added, diff.updated);
  }
});

Incremental watch with original APIs

The original AsyncIterator and callback styles still work, and now accept a comparator to support incremental updates (so they only emit results when data actually changes):

// AsyncIterator
for await (const res of db.watch(
  'SELECT * FROM lists WHERE state = ?',
  ['pending'],
  {
    comparator: { checkEquality: (c, p) => JSON.stringify(c) === JSON.stringify(p) }
  }
)) {
  /* … */
}

// Callback
db.watch(
  'SELECT * FROM lists WHERE state = ?',
  ['pending'],
  { onResult: ({ rows }) => render(rows._array) },
  { comparator: { checkEquality: fastDeepEqual } }
);

While these methods will be maintained for backwards compatibility, we recommend using the improved WatchedQuery APIs mentioned above.

Getting started

The existing db.watch(...) methods will keep working, so you can move to the new WatchedQuery API on your own schedule. That said, the new API brings several improvements - multiple listeners on a single query, built‑in loading / fetching / error state objects, and of course the new incremental & differential modes - so we do recommend upgrading.

  1. Update to the latest SDK versions:

    • Web v1.25.0

    • React Native v1.23.1

    • Node.js v0.8.1

  2. Replace db.watch(...) calls with db.query().watch() where the improved API or incremental updates are desired.

  3. Use .differentialWatch() when you need fine‑grained change sets.

  4. For React/Vue, pass rowComparator to existing hooks or use useWatchedQuerySubscription for sharing query instances across components.

See full details and examples in the “Live Queries / Watch Queries” docs page.

If you want to see any implementation details, check the PR on GitHub.

Questions & feedback

As always, we'd love to hear your about your experience with these new methods. Please share your feedback on Discord, and let us know if you need any assistance.

Powered by LaunchNotes