Realtime Experimental
RedwoodSDK provides built-in support for real-time applications through shared state synchronization. The primary way to implement this is via the useSyncedState hook.
useSyncedState looks exactly like React’s native useState, except it has bidirectional syncing with the server and all other connected clients.
What is it?
Section titled “What is it?”- It’s a hook that synchronizes state across multiple clients (tabs, devices, users) in real-time.
- The server is the source of truth.
- It allows you to build collaborative features without needing an external realtime service.
Why would you use it?
Section titled “Why would you use it?”- Realtime: Updates are instant for all users on the page.
- Native: It’s built into Redwood SDK.
- Cloudflare: It leverages Cloudflare Durable Objects for coordination.
Where would you use it?
Section titled “Where would you use it?”- Any component where you want data to update instantly for everyone.
- Examples: Chat apps, collaborative forms, live dashboards, presence indicators.
Tutorial: From 0 to 1
Section titled “Tutorial: From 0 to 1”Here is the easiest way to get started.
1. Setup the Worker
Section titled “1. Setup the Worker”In your src/worker.tsx, you need to export the SyncedStateServer (the Durable Object) and register its routes.
import { env } from "cloudflare:workers";import { SyncedStateServer, syncedStateRoutes,} from "rwsdk/use-synced-state/worker";import { defineApp } from "rwsdk/worker";
// 1. Export the Durable Object so Cloudflare can find itexport { SyncedStateServer };
export default defineApp([ // ... your other middleware // 2. Register the synced state routes ...syncedStateRoutes(() => env.SYNCED_STATE_SERVER),]);2. Update Wrangler Config
Section titled “2. Update Wrangler Config”You need to tell Cloudflare about the Durable Object. Add the following to your wrangler.jsonc:
"durable_objects": { "bindings": [ { "name": "SYNCED_STATE_SERVER", "class_name": "SyncedStateServer" } ]},"migrations": [ { "tag": "v1", "new_sqlite_classes": ["SyncedStateServer"] }]Note: After changing
wrangler.jsonc, runpnpm generateto update your types.
3. Use the Hook
Section titled “3. Use the Hook”Now you can use useSyncedState in your components. It works just like useState, but takes a second argument: a unique key, and an optional third argument: a room ID.
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const SharedCounter = () => { // "counter" is the unique key for this piece of state // Without a room ID, this state is global across all clients const [count, setCount] = useSyncedState(0, "counter");
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount((c) => c + 1)}>Increment</button> </div> );};Open this component in two different browser windows. When you click increment in one, it updates in the other instantly!
Rooms: Scoping State to Different Groups
Section titled “Rooms: Scoping State to Different Groups”By default, state is global. But you can scope state to different “rooms” by passing a room ID as the third argument. This is useful for features like chat rooms, game sessions, or collaborative documents.
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const RoomCounter = ({ roomId }: { roomId: string }) => { // State is scoped to this specific room // Users in different rooms won't see each other's updates const [count, setCount] = useSyncedState(0, "counter", roomId);
return ( <div> <p>Room: {roomId}</p> <p>Count: {count}</p> <button onClick={() => setCount((c) => c + 1)}>Increment</button> </div> );};When you use a room ID, state is isolated to that room. Users in "room-1" won’t see updates from users in "room-2".
Advanced: Scoping and Persistence
Section titled “Advanced: Scoping and Persistence”Scoping State with Room IDs vs Key Handlers
Section titled “Scoping State with Room IDs vs Key Handlers”There are two ways to scope state:
-
Room IDs (client-side): Pass a room ID as the third argument to
useSyncedState. This is the simplest way to isolate state between different groups. -
Key Handlers (server-side): Transform keys on the server to add prefixes or scoping logic. This is useful when you need server-enforced scoping based on authentication or other server-side data.
Using Room IDs (Client-Side)
Section titled “Using Room IDs (Client-Side)”"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const ChatRoom = ({ roomId }: { roomId: string }) => { // Each room has its own isolated state const [messages, setMessages] = useSyncedState<string[]>([], "messages", roomId);
// ... chat UI};Using Key Handlers (Server-Side)
Section titled “Using Key Handlers (Server-Side)”Key handlers allow you to transform keys on the server, which is useful for server-enforced scoping:
import { requestInfo } from "rwsdk/worker";
SyncedStateServer.registerKeyHandler(async (key, stub) => { // Access user ID from request context const userId = requestInfo.ctx.userId;
// Scope keys that start with "user:" to the current user if (key.startsWith("user:")) { return `${key}:${userId}`; }
return key;});Then in your component:
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const UserSettings = () => { // The key handler will transform this to "user:settings:123" (where 123 is the userId) // Each user gets their own isolated settings const [settings, setSettings] = useSyncedState({}, "user:settings");
// ... settings UI};Server-Side Room Transformation
Section titled “Server-Side Room Transformation”You can also transform room IDs on the server using a room handler. This is useful for features like “private” rooms that should be scoped per user:
import { requestInfo } from "rwsdk/worker";
SyncedStateServer.registerRoomHandler(async (roomId, reqInfo) => { const userId = reqInfo?.ctx?.userId;
// Transform "private" room requests to user-specific rooms if (roomId === "private" && userId) { return `user:${userId}`; }
// Pass through other room IDs as-is return roomId ?? "syncedState";});Then clients can request a “private” room, and the server will automatically scope it to the current user:
"use client";
import { useSyncedState } from "rwsdk/use-synced-state/client";
export const PrivateNotes = () => { // Server transforms "private" to "user:${userId}" automatically const [notes, setNotes] = useSyncedState("", "notes", "private");
// ... notes UI};Persisting State
Section titled “Persisting State”Since the state is in-memory, you might want to save it to a database. You can register handlers for when state is set or retrieved.
SyncedStateServer.registerSetStateHandler((key, value) => { console.log("State updated:", key, value); // db.save(key, value);});
SyncedStateServer.registerGetStateHandler((key, value) => { // potentially load from DB if value is undefined});Future Plans
Section titled “Future Plans”We are working on making this even more powerful out of the box:
- Offline Support: Local persistence (e.g., via IndexedDB) so your app works offline and syncs changes when the connection is restored.
- Durable Storage: Built-in persistence to the Durable Object’s SQLite storage, ensuring state survives worker restarts without custom handlers.