Data storage
Define a collection, query it from React, and persist realtime writes.
On this page
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 mutation 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/schema'
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/schema'
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 start 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 fire-and-forget create / put / remove functions plus *Confirmed variants. Query state updates when the accepted server broadcast returns.
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. Plaincreatereturns the client-generated ID before the server answers.createConfirmedwaits for the Durable Object ack and rejects the promise on RBAC or validation 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.