Skip to content

Currency transfers example

This example shows a complete flow for currency transfers: a client-side collection hook that syncs over WebSockets and a DO RPC server that owns the authoritative currency state.

This hook creates a TanStack DB collection, wires outbound mutations to RPC calls, and listens for inbound events to update local state.

import { createCollection, localStorageCollectionOptions } from '@tanstack/db';
import { createSubscriptionClient } from '@repo/client-side-do';
import { useEffect, useMemo } from 'react';
type CurrencyRecord = {
inventoryId: string;
platinum: number;
gold: number;
electrum: number;
silver: number;
copper: number;
};
type CurrencyRpc = {
updateCurrency: (args: {
currency: Partial<CurrencyRecord>;
}) => Promise<CurrencyRecord>;
transferCurrency: (args: {
transferId: string;
fromInventoryId: string;
toInventoryId: string;
currency: Partial<CurrencyRecord>;
}) => Promise<{
transferId: string;
fromInventoryId: string;
toInventoryId: string;
currency: Partial<CurrencyRecord>;
}>;
};
type CurrencyAction =
| {
type: 'updateCurrency';
payload: Parameters<CurrencyRpc['updateCurrency']>[0];
}
| {
type: 'transferCurrency';
payload: Parameters<CurrencyRpc['transferCurrency']>[0];
};
const currencyCollection = createCollection<CurrencyRecord>(
localStorageCollectionOptions({
storageKey: 'currency-db',
getKey: (record) => record.inventoryId,
})
);
const buildWsUrl = (inventoryId: string) =>
`wss://example.com/api/inventory/${inventoryId}/ws`;
export function useCurrencyCollectionHook(inventoryIds: string[]) {
const buildSubscriptionClient = useMemo(() => {
return (
id: string,
handlers: Parameters<
ReturnType<typeof createSubscriptionClient<CurrencyRpc>>
>[1]
) =>
createSubscriptionClient<CurrencyRpc>({ baseUrl: buildWsUrl(id) })(
id,
handlers
);
}, []);
const clientsById = useMemo(() => {
return new Map(
inventoryIds.map((id) => {
const client = buildSubscriptionClient(id, {} as never);
return [id, client] as const;
})
);
}, [inventoryIds, buildSubscriptionClient]);
useEffect(() => {
const handleInsert = async (record: CurrencyRecord, metadata?: unknown) => {
const action = (metadata as { action?: CurrencyAction })?.action;
if (!action) {
return;
}
const client = clientsById.get(record.inventoryId);
if (!client) {
return;
}
if (action.type === 'updateCurrency') {
await client.client.updateCurrency(action.payload);
}
if (action.type === 'transferCurrency') {
await client.client.transferCurrency(action.payload);
}
};
const unsubscribe = currencyCollection.subscribe(
({ event, record, metadata }) => {
if (event === 'insert' || event === 'update') {
void handleInsert(record, metadata);
}
}
);
return () => unsubscribe();
}, [clientsById]);
useEffect(() => {
const unsubscribers: Array<() => void> = [];
clientsById.forEach((client, inventoryId) => {
const handlers = {
updateCurrency: ({ result }: { result?: CurrencyRecord }) => {
if (!result) {
return;
}
const record = { inventoryId, ...result };
if (!currencyCollection.get(record.inventoryId)) {
currencyCollection.insert(record);
}
currencyCollection.update(record.inventoryId, (draft) => ({
...draft,
...record,
}));
},
transferCurrency: ({
result,
}: {
result?: CurrencyRpc['transferCurrency'] extends (
...args: never[]
) => infer R
? Awaited<R>
: never;
}) => {
if (!result) {
return;
}
const fromRecord = currencyCollection.get(result.fromInventoryId);
if (fromRecord) {
currencyCollection.update(result.fromInventoryId, (draft) => {
draft.platinum -= result.currency.platinum ?? 0;
draft.gold -= result.currency.gold ?? 0;
draft.electrum -= result.currency.electrum ?? 0;
draft.silver -= result.currency.silver ?? 0;
draft.copper -= result.currency.copper ?? 0;
});
}
if (!currencyCollection.get(result.toInventoryId)) {
currencyCollection.insert({
inventoryId: result.toInventoryId,
platinum: 0,
gold: 0,
electrum: 0,
silver: 0,
copper: 0,
});
}
currencyCollection.update(result.toInventoryId, (draft) => {
draft.platinum += result.currency.platinum ?? 0;
draft.gold += result.currency.gold ?? 0;
draft.electrum += result.currency.electrum ?? 0;
draft.silver += result.currency.silver ?? 0;
draft.copper += result.currency.copper ?? 0;
});
},
} as const;
const doClient = buildSubscriptionClient(inventoryId, handlers as never);
unsubscribers.push(doClient.unsubscribe);
});
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
}, [clientsById, buildSubscriptionClient]);
return currencyCollection;
}

If you want declarative inbound/outbound handlers, you can use createTanstackCollectionFromClient instead of manually wiring subscriptions. This example includes inventoryId in the update payload so the inbound handler knows which record to update.

import { createCollection, localStorageCollectionOptions } from '@tanstack/db';
import {
createSubscriptionClient,
createTanstackCollectionFromClient,
} from '@repo/client-side-do';
import { useEffect, useMemo } from 'react';
type CurrencyRecord = {
inventoryId: string;
platinum: number;
gold: number;
electrum: number;
silver: number;
copper: number;
};
type CurrencyRpc = {
updateCurrency: (args: {
inventoryId: string;
currency: Partial<CurrencyRecord>;
}) => Promise<CurrencyRecord>;
transferCurrency: (args: {
transferId: string;
fromInventoryId: string;
toInventoryId: string;
currency: Partial<CurrencyRecord>;
}) => Promise<{
transferId: string;
fromInventoryId: string;
toInventoryId: string;
currency: Partial<CurrencyRecord>;
}>;
};
let currencyHandlers = {
onInsert: async () => {},
onUpdate: async () => {},
onDelete: async () => {},
};
const currencyCollection = createCollection<CurrencyRecord>(
localStorageCollectionOptions({
storageKey: 'currency-db',
getKey: (record) => record.inventoryId,
onInsert: (params) => currencyHandlers.onInsert(params),
onUpdate: (params) => currencyHandlers.onUpdate(params),
onDelete: (params) => currencyHandlers.onDelete(params),
})
);
const buildWsUrl = (inventoryId: string) =>
`wss://example.com/api/inventory/${inventoryId}/ws`;
export function useCurrencyCollectionBuilder(inventoryIds: string[]) {
const buildSubscriptionClient = useMemo(() => {
return (
id: string,
handlers: Parameters<
ReturnType<typeof createSubscriptionClient<CurrencyRpc>>
>[1]
) =>
createSubscriptionClient<CurrencyRpc>({ baseUrl: buildWsUrl(id) })(
id,
handlers
);
}, []);
const subscriptionsById = useMemo(() => {
return new Map(
inventoryIds.map((id) => {
const subscription = buildSubscriptionClient(id, {} as never);
return [id, subscription] as const;
})
);
}, [inventoryIds, buildSubscriptionClient]);
useEffect(() => {
const builder = createTanstackCollectionFromClient<
CurrencyRpc,
CurrencyRecord
>({
collection: currencyCollection,
resolveSubscription: ({ record }) =>
subscriptionsById.get(record.inventoryId) ?? null,
});
const { mutationHandlers, buildSubscriptionHandlers } = builder
.method('updateCurrency', {
outbound: async ({ client, args, metadata }) => {
const meta = metadata as { source?: string } | undefined;
if (meta?.source === 'bootstrap') {
return;
}
await client.updateCurrency(...args);
},
inbound: async ({ collection, payload }) => {
const currency = payload.result;
if (!currency) {
return;
}
const record = {
inventoryId: payload.args[0].inventoryId,
...currency,
};
if (!collection.get(record.inventoryId)) {
collection.insert(record);
}
collection.update(record.inventoryId, (draft) => ({
...draft,
...record,
}));
},
})
.method('transferCurrency', {
outbound: async ({ client, args, metadata }) => {
const meta = metadata as { source?: string } | undefined;
if (meta?.source === 'bootstrap') {
return;
}
await client.transferCurrency(...args);
},
inbound: async ({ collection, payload }) => {
const result = payload.result ?? payload.args[0];
if (!result) {
return;
}
const fromRecord = collection.get(result.fromInventoryId);
const toRecord = collection.get(result.toInventoryId);
if (fromRecord) {
collection.update(result.fromInventoryId, (draft) => {
draft.platinum -= result.currency.platinum ?? 0;
draft.gold -= result.currency.gold ?? 0;
draft.electrum -= result.currency.electrum ?? 0;
draft.silver -= result.currency.silver ?? 0;
draft.copper -= result.currency.copper ?? 0;
});
}
if (!toRecord) {
collection.insert({
inventoryId: result.toInventoryId,
platinum: 0,
gold: 0,
electrum: 0,
silver: 0,
copper: 0,
});
}
collection.update(result.toInventoryId, (draft) => {
draft.platinum += result.currency.platinum ?? 0;
draft.gold += result.currency.gold ?? 0;
draft.electrum += result.currency.electrum ?? 0;
draft.silver += result.currency.silver ?? 0;
draft.copper += result.currency.copper ?? 0;
});
},
})
.done();
currencyHandlers = {
onInsert: mutationHandlers.onInsert ?? (async () => {}),
onUpdate: mutationHandlers.onUpdate ?? (async () => {}),
onDelete: mutationHandlers.onDelete ?? (async () => {}),
};
const unsubscribers: Array<() => void> = [];
subscriptionsById.forEach((subscription, inventoryId) => {
const handlers = buildSubscriptionHandlers(() => {
const current = subscriptionsById.get(inventoryId);
if (!current) {
throw new Error('Subscription not ready');
}
return current;
});
const doClient = buildSubscriptionClient(inventoryId, handlers);
unsubscribers.push(doClient.unsubscribe);
});
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
}, [subscriptionsById, buildSubscriptionClient]);
return currencyCollection;
}

Mutation metadata with builder:

collection.update(
fromInventoryId,
(draft) => {
draft.gold -= 10;
},
{
action: {
type: 'transferCurrency',
args: [
{
transferId,
fromInventoryId,
toInventoryId,
currency: { gold: 10 },
},
],
},
}
);

This DO owns the currency state and emits events whenever updates or transfers complete.

import { DurableObject } from 'cloudflare:workers';
import {
createRpcServer,
RpcFromTarget,
type DurableObjectLike,
} from '@repo/client-side-do';
type InventoryCurrency = {
platinum: number;
gold: number;
electrum: number;
silver: number;
copper: number;
};
const rpcMethods = ['updateCurrency', 'transferCurrency'] as const;
type CurrencyRpc = Pick<
RpcFromTarget<InstanceType<typeof CurrencyDO>, 'fetch'>,
(typeof rpcMethods)[number]
>;
export class CurrencyDO extends DurableObject {
private rpcServer: ReturnType<typeof createRpcServer>;
private static readonly currencyKey = 'currency';
constructor(ctx: DurableObjectState, env: Record<string, unknown>) {
super(ctx, env);
this.rpcServer = createRpcServer(
this as unknown as DurableObjectLike,
rpcMethods
);
}
async getCurrency(): Promise<InventoryCurrency> {
const stored = await this.ctx.storage.kv.get<InventoryCurrency>(
CurrencyDO.currencyKey
);
return {
platinum: stored?.platinum ?? 0,
gold: stored?.gold ?? 0,
electrum: stored?.electrum ?? 0,
silver: stored?.silver ?? 0,
copper: stored?.copper ?? 0,
};
}
async updateCurrency({
currency,
}: {
currency: Partial<InventoryCurrency>;
}): Promise<InventoryCurrency> {
const current = await this.getCurrency();
const nextCurrency: InventoryCurrency = {
platinum: currency.platinum ?? current.platinum,
gold: currency.gold ?? current.gold,
electrum: currency.electrum ?? current.electrum,
silver: currency.silver ?? current.silver,
copper: currency.copper ?? current.copper,
};
await this.ctx.storage.kv.put(CurrencyDO.currencyKey, nextCurrency);
return nextCurrency;
}
async transferCurrency({
transferId,
fromInventoryId,
toInventoryId,
currency,
}: {
transferId: string;
fromInventoryId: string;
toInventoryId: string;
currency: Partial<InventoryCurrency>;
}): Promise<{
transferId: string;
fromInventoryId: string;
toInventoryId: string;
currency: Partial<InventoryCurrency>;
}> {
// Delegate multi-inventory coordination to the Hoarder DO.
const result = await this.env.HOARDER.transferCurrency({
transferId,
fromInventoryId,
toInventoryId,
currency,
});
if (result.status !== 'completed') {
throw new Error(result.reason ?? 'Transfer failed');
}
return {
transferId,
fromInventoryId,
toInventoryId,
currency,
};
}
async fetch(request: Request): Promise<Response> {
return this.rpcServer.fetch(request);
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
await this.rpcServer.handleMessage(ws, message);
}
webSocketClose(ws: WebSocket) {
this.rpcServer.handleClose(ws);
}
webSocketError(ws: WebSocket, error: unknown) {
this.rpcServer.handleError(ws, error);
}
}
  • Mutate the collection with metadata like { action: { type: 'updateCurrency', payload: { currency: { gold: 5 } } } }.
  • The DO updates its storage, emits an event, and the hook updates local records.

Get the currency for a specific inventory:

const collection = useCurrencyCollectionHook([inventoryId]);
const currency = collection.get(inventoryId);

Update currency for a single inventory:

collection.update(
inventoryId,
(draft) => {
draft.gold += 5;
},
{
action: {
type: 'updateCurrency',
payload: { currency: { gold: 5 } },
},
}
);

Transfer currency between inventories (optimistically update source only):

const transferId = crypto.randomUUID();
collection.update(
fromInventoryId,
(draft) => {
draft.gold -= 10;
},
{
action: {
type: 'transferCurrency',
payload: {
transferId,
fromInventoryId,
toInventoryId,
currency: { gold: 10 },
},
},
}
);

Inbound transfer handler (updates both source and target):

transferCurrency: {
update: async ({ payload, collection }) => {
const result = payload.result ?? payload.args[0];
if (!result) {
return;
}
const fromRecord = collection.get(result.fromInventoryId);
const toRecord = collection.get(result.toInventoryId);
if (fromRecord) {
collection.update(result.fromInventoryId, (draft) => {
draft.platinum -= result.currency.platinum ?? 0;
draft.gold -= result.currency.gold ?? 0;
draft.electrum -= result.currency.electrum ?? 0;
draft.silver -= result.currency.silver ?? 0;
draft.copper -= result.currency.copper ?? 0;
});
}
if (!toRecord) {
collection.insert({
inventoryId: result.toInventoryId,
platinum: 0,
gold: 0,
electrum: 0,
silver: 0,
copper: 0,
});
}
collection.update(result.toInventoryId, (draft) => {
draft.platinum += result.currency.platinum ?? 0;
draft.gold += result.currency.gold ?? 0;
draft.electrum += result.currency.electrum ?? 0;
draft.silver += result.currency.silver ?? 0;
draft.copper += result.currency.copper ?? 0;
});
},
}