Data storage
Define a collection, query it from React, and mutate it with optimistic updates.
This guide walks through adding a new collection to your app: declaring its schema, querying it from the client, and persisting writes. By the end, you'll have a working CRUD page backed by a Durable Object that syncs in real time across every connected client.
For background on envelopes, scopes, and the optimistic pipeline, see Data model and Real-time sync.
Define the schema#
Schemas live under src/schemas/, one file per collection. Create a new collection called notes:
// src/schemas/notes-schema.ts
import type { CollectionSchema } from 'deepspace/worker'
export const notesSchema: CollectionSchema = {
name: 'notes',
columns: [
{ name: 'title', storage: 'text', interpretation: 'plain' },
{ name: 'body', storage: 'text', interpretation: 'plain' },
{ name: 'pinned', storage: 'number', interpretation: { kind: 'boolean' } },
],
permissions: {
member: { read: true, create: true, update: 'own', delete: 'own' },
admin: { read: true, create: true, update: true, delete: true },
},
}
Register it in src/schemas.ts:
import type { CollectionSchema } from 'deepspace/worker'
import { usersSchema } from './schemas/users-schema'
import { settingsSchema } from './schemas/admin-schema'
import { notesSchema } from './schemas/notes-schema'
export const schemas: CollectionSchema[] = [usersSchema, settingsSchema, notesSchema]
Restart npx deepspace dev if it's running - schemas are picked up at worker startup.
Read records#
useQuery subscribes to a collection and streams updates over a WebSocket. Each record arrives as an envelope, with your fields under .data:
import { useQuery } from 'deepspace'
type Note = { title: string; body: string; pinned: boolean }
function NotesList() {
const { records, status, error } = useQuery<Note>('notes', {
orderBy: 'createdAt',
orderDir: 'desc',
})
if (status === 'loading') return <p>Loading…</p>
if (status === 'error') return <p>Error: {error}</p>
return (
<ul>
{records.map((note) => (
<li key={note.recordId}>
<h3>{note.data.title}</h3>
<p>{note.data.body}</p>
</li>
))}
</ul>
)
}
For filtering, sorting, and limit options, see useQuery options. The Durable Object applies where server-side before broadcasting, so unauthorized records never leave the worker.
Write records#
useMutations returns create / put / remove plus *Confirmed variants. Each mutation applies optimistically - the local store updates before the server confirms.
import { useMutations } from 'deepspace'
const { create, put, remove } = useMutations<Note>('notes')
const id = await create({ title: 'Untitled', body: '', pinned: false })
await put(id, { pinned: true }) // merge: only updates pinned
await remove(id)
createreturns therecordIdimmediately. The ID is generated client-side (timestamp + random suffix) before the write is sent, so you can navigate to/notes/${id}without waiting for the server.putis merge semantics. The server applies{ ...existing, ...patch }. Send only the fields you're changing - don't spread the whole row.removeis a hard delete. There's no soft-delete primitive at the records layer.- Use
createConfirmedwhen persistence matters before the next step. Plaincreateresolves on local update; if the server later rejects (RBAC, validation), the optimistic write rolls back.createConfirmedwaits for the Durable Object ack and rejects the promise on server denial.
See the reference for the full method table.
A complete CRUD example#
import { useState } from 'react'
import { useQuery, useMutations } from 'deepspace'
import { useToast } from '../components/ui'
type Note = { title: string; body: string; pinned: boolean }
export default function NotesPage() {
const { records, status } = useQuery<Note>('notes', { orderBy: 'createdAt', orderDir: 'desc' })
const { create, put, remove } = useMutations<Note>('notes')
const { success, error } = useToast()
const [draft, setDraft] = useState('')
async function addNote() {
if (!draft.trim()) return
try {
await create({ title: draft, body: '', pinned: false })
setDraft('')
success('Note created')
} catch (e) {
error('Could not create note', String(e))
}
}
if (status === 'loading') return <p>Loading…</p>
return (
<div>
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="New note…" />
<button onClick={addNote}>Add</button>
<ul>
{records.map((note) => (
<li key={note.recordId}>
<input
type="checkbox"
checked={note.data.pinned}
onChange={(e) => put(note.recordId, { pinned: e.target.checked })}
/>
{note.data.title}
<button onClick={() => remove(note.recordId)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
Open the page in two browser windows - changes in one window appear in the other in real time.
JSON columns#
For structured field data, use interpretation: { kind: 'json' }:
{ name: 'tags', storage: 'text', interpretation: { kind: 'json' } }
The SDK serializes on write and parses on read - pass and receive the value directly, no JSON.stringify / JSON.parse on either end.
await create({ title: 'Trip', tags: ['vacation', '2024'] })
// later
record.data.tags // ['vacation', '2024']
Per-user privacy#
Permissions are enforced server-side. To make notes private per user:
permissions: {
member: { read: 'own', create: true, update: 'own', delete: 'own' },
}
'own' resolves against record.createdBy by default; override with ownerField if a different column determines ownership. For published/draft visibility, collaborators, and team rules, see Permissions.
Performance tips#
- Filter at the query.
whereruns server-side, so unauthorized or unwanted rows never cross the wire. - Use
limiton long lists. Initial subscription sends every matching record. For thousands of rows, slice by date or category. (Cursor pagination is on the roadmap.) - Lift
useQueryto a parent. Identical queries deduplicate to one WebSocket subscription, but each mount still re-renders on every store change - fetch once at the top of the page and pass records down via props. - Patch, don't replace.
putwith a partial patch keeps the wire payload small and avoids overwriting concurrent edits.
Next steps#
- Permissions - role-based access control on collections.
- Server actions - privileged writes that bypass user RBAC.
- Records reference - full hooks API surface.
- Schema reference - column types and permission rules.