Getting Started

Keep data up to date

AI generated The content on this page was written using large language models.

Your published Feature can read data from your app. Now we’ll keep it up to date when that data changes, without asking the user to refresh the page.

A Subscription sends a value when the Feature starts listening, then sends another whenever the data changes. We’ll connect it to the same source of updates your app’s own UI uses.

Find where your app observes changes

You don’t need to build a new store for Rough. Use an existing store subscription, query-cache observer, or live-data client. If your app uses polling, those refreshed results can feed the Subscription too.

Rough doesn’t watch your database. Changes from another tab, user, or device only reach a Feature if your app’s data layer receives them and notifies its listeners.

Keep React hooks in components or custom hooks, not inside the capability implementation. Connect the capability to your data library’s imperative observer or store API. If your data currently lives only in a Server Component, it won’t become a live browser subscription automatically; use your existing client-side data layer to receive updates.

Extend your data adapter

In this example, observeBooks stands for your app’s existing observer. Replace it and the import path with the equivalent in your product. We are not adding a books store or requiring a new books endpoint.

The adapter below assumes the observer:

  • Sends the current, loaded list when a listener subscribes, then sends updates.
  • Is scoped to the account passed to it and the current authenticated user.
  • Returns a function that removes the listener.
  • Handles loading and failures through your app’s data layer, without presenting failed loads as a successful empty result.

If your observer has a different interface, adapt it here. In particular, a change-only event emitter needs an initial snapshot, and an observer that returns a handle needs a wrapper that calls its unsubscribe method.

lib/rough/data-source.ts
import { listBooks, observeBooks } from '@/lib/app/data' // Your app's functions.
import { booksSchema } from './data'
import type { Book } from './data'

export type DashboardData = {
  read: () => Promise<Book[]>
  watch: (emit: (books: Book[]) => void) => () => void
}

export function createDashboardData(accountId: string): DashboardData {
  return {
    read: async () => booksSchema.parse(await listBooks({ accountId })),
    watch: (emit) =>
      observeBooks({ accountId }, (books) => {
        emit(booksSchema.parse(books))
      }),
  }
}

Use your own schema and model in place of Book. If you map or filter the read result, apply the same transformation to subscription values so both capabilities describe the same data. A schema mismatch is an integration error to report and fix, not a reason to emit an empty list.

Keep loading state distinct from “there are no records.” If your store starts out unloaded, wait for its first real result before emitting; don’t pass undefined to an array schema. Register for changes in a way that doesn’t miss updates between reading a snapshot and attaching a listener.

Add a Subscription capability

lib/rough/subscription.ts
import { Subscription } from '@roughapp/feature'
import { z } from 'zod'
import { booksSchema, sampleBooks } from './data'
import type { DashboardData } from './data-source'

export function booksSubscription(data: DashboardData) {
  return new Subscription({
    id: 'watchBooks',
    name: 'Watch books',
    description:
      'Sends the current books the signed-in user can access, then a new list whenever those books change.',
    inputSchema: z.object({}),
    outputSchema: booksSchema,
    outputSample: sampleBooks,
    implementation: async (emit) => data.watch(emit),
  })
}

Rough calls the returned cleanup function when the Feature stops listening. Make sure your observer really detaches at that point, including any per-listener timers or connections it created.

Keep the Query too

Add the Subscription alongside the Query, rather than replacing it. The Feature you published in step 4 still uses the Query; keeping it avoids breaking that build while you update it.

lib/rough/surface.ts
import { defineRoughSurface } from '@roughapp/feature'
import { booksQuery } from './query'
import { booksSubscription } from './subscription'
import type { DashboardData } from './data-source'

export function createDashboardSurface(data: DashboardData) {
  return defineRoughSurface({
    key: 'dashboard',
    name: 'Reading dashboard',
    description: 'A place to organise books and track reading.',
    toolList: [booksQuery(data), booksSubscription(data)],
  })
}

There’s no change to the page integration from step 4. It already calls createDashboardData and createDashboardSurface for the active identity, so it picks up these definitions on the next load. Keep the Surface key unchanged.

Update your Feature

Reload your app and open the existing Feature with its edit control. Check that the Builder lists your new Subscription, then ask it to use it. For the books example:

Use Watch books to keep the list up to date as reading statuses change, without refreshing the page.

Use your own capability name and data in the prompt. Build and publish the update, then close the Builder.

Now change a record through your app’s existing UI, outside the Feature. You don’t need to build a special test control. The published Feature should update as soon as your app’s observer reports the change.

If it doesn’t, check these separately:

  1. Does your adapter’s listener receive an initial value and the later change?
  2. Is the new Feature build published, and does it use the Subscription rather than only the Query?
  3. Is the observer watching the same account and data as your app’s UI?

Test this on the published Feature. Builder previews use samples, not your live subscription.

If you promise cross-user updates in your product, test a change from a second session too. If your app only observes local changes, Rough won’t add cross-user sync on its own. When the view unmounts or the account changes, also check that the old listener stops receiving values.