Getting Started
Let Features make changes
React
Your Feature can read data and follow changes from your app. Let’s let users make a change from the Feature too.
A Mutation exposes a write operation. We’ll connect one to your app’s existing save path, so changes made in a Feature follow the same permissions, validation, and data updates as changes made in your own UI.
Choose one useful change
Start with a small operation users can already perform: change a ticket’s priority, update an order note, or assign a task. In our books example, we’ll change a reading status.
You don’t need to implement that books operation. Choose an existing write in your product and use its input and result shapes below. Avoid exposing a broad “update anything” operation when a narrower capability will do.
Connect the save to your data layer
Extend the adapter from step 5 with your existing save function. saveBookStatus below is an illustrative app function, not part of Rough. It returns the record the backend actually saved and updates or invalidates the same store/cache that observeBooks watches.
That last part matters: a successful request alone doesn’t notify a Subscription. Reuse the path your app’s own UI uses to update its data, including its cache refresh or live-data notification.
import { listBooks, observeBooks, saveBookStatus } from '@/lib/app/data'
import { bookSchema, booksSchema } from './data'
import type { Book } from './data'
export type DashboardData = {
read: () => Promise<Book[]>
watch: (emit: (books: Book[]) => void) => () => void
updateReadingStatus: (
bookId: string,
readingStatus: Book['readingStatus'],
) => Promise<Book>
}
export function createDashboardData(accountId: string): DashboardData {
return {
read: async () => booksSchema.parse(await listBooks({ accountId })),
watch: (emit) =>
observeBooks({ accountId }, (books) => {
emit(booksSchema.parse(books))
}),
updateReadingStatus: async (bookId, readingStatus) => {
// This is your app's normal save-and-refresh path, not a raw fetch
// that leaves the observer's cache unchanged.
const saved = await saveBookStatus({ accountId, bookId, readingStatus })
return bookSchema.parse(saved)
},
}
}
If your save API returns no record, adapt the Mutation’s output schema to its real result, or fetch the authoritative saved record through your normal data layer. Don’t return the requested input as though it were proof of a successful save.
Failed saves must reject. If your app uses optimistic updates, keep its existing rollback and error handling; don’t let the Feature claim a change was saved when it wasn’t.
Define the Mutation
The input schema describes what the Feature must supply. The output schema describes the saved result. Use the same model and field names you’ve used throughout the guide:
import { Mutation } from '@roughapp/feature'
import { z } from 'zod'
import { bookSchema, sampleBooks, statusSchema } from './data'
import type { DashboardData } from './data-source'
export function readingStatusMutation(data: DashboardData) {
return new Mutation({
id: 'updateReadingStatus',
name: 'Update reading status',
description:
'Changes the reading status of a book the user can edit. Use an ID from Get books or Watch books. Returns the saved book.',
inputSchema: z.object({
bookId: z.string(),
readingStatus: statusSchema,
}),
outputSchema: bookSchema,
outputSample: { ...sampleBooks[0]!, readingStatus: 'finished' },
implementation: ({ bookId, readingStatus }) =>
data.updateReadingStatus(bookId, readingStatus),
})
}
Schemas validate the shape of a request, not the user’s permission to make it. Your backend still needs to authenticate the caller, authorise the selected record and account, validate the change, and apply your normal CSRF protection. Treat IDs from a Feature like IDs from any other browser UI.
For destructive or sensitive operations, use your app’s existing confirmation and authorisation safeguards. A prompt asking the generated UI to be careful isn’t a security boundary.
Add it to the Surface
Keep the Query and Subscription and add the Mutation alongside them:
import { defineRoughSurface } from '@roughapp/feature'
import { booksQuery } from './query'
import { booksSubscription } from './subscription'
import { readingStatusMutation } from './mutation'
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),
readingStatusMutation(data),
],
})
}
The page integration stays the same. The Mutation saves through your data adapter; the Subscription delivers the resulting data to Features. Your app’s own UI should receive that change through the same store or cache.
Let your Feature use the write
Reload your app and open the Feature in the Builder. Check that your Mutation is listed, then ask for the corresponding interaction. For our example:
Let me change a book’s reading status using Update reading status. Show when a change is saving, and show an error if it fails. Keep using Watch books to display the saved data.
Use your own capability names and operation. Build and publish the change, then test it in your app—not just the preview. Previews use sample outputs and don’t run your save implementation.
Check the whole round trip
- Make a change in the published Feature. Check that your app’s existing UI shows the saved value too.
- Make another change through your app’s UI. The Feature should update without a page refresh.
- Reload the page. Both should still show the saved data.
- Try a failed save in a development environment. The Feature should report the failure, not claim success; any optimistic update should roll back.
- Test with a user who lacks permission for the operation. The backend must refuse it, even if a request is sent directly.
- Switch accounts or sign out. Features and subscriptions from the previous identity should be torn down, and the next identity must not receive the previous account’s data.
If the save succeeds but one view stays stale, check the cache/store notification path before adding refresh calls to the Feature. Both views should be using the same source of truth.
You’re connected
You now have Rough using your app’s login and data. Users can build Features that read, follow, and update the data you’ve chosen to expose, while your app continues to enforce its own permissions.
Start with these few capabilities and add more as you learn what people want to build. Clear descriptions and realistic fictional samples help the Builder use them well. Keep Surface keys and capability IDs stable, and don’t remove capabilities that published Features still rely on without a migration.
The SDK reference covers options such as theming, individual Feature mounts, and custom Feature-state storage. Security explains the boundaries between your app, Rough, and generated Features.