Documentation

Everything you need to add real-time synced state to your app.

Quick Start

Two commands. Zero cloning.

1. Start the server

npx @better-state/server

Auto-creates a default namespace, prints your API key, and starts the server on http://localhost:3001. Data is stored in .better-state/ in your current directory.

2. Install the client SDK

npm install @better-state/client

Or for React projects:

npm install @better-state/react @better-state/client

3. Use it

app.ts
1import { createClient } from '@better-state/client'
2 
3const client = createClient('http://localhost:3001', {
4 apiKey: 'your-api-key',
5})
6 
7const todos = client.state('todos', [])
8 
9todos.subscribe(value => {
10 console.log('todos:', value)
11})
12 
13// update(fn) is client-side sugar: computes a new value locally and sends a safe op to the server
14todos.update(list => [...list, { id: '1', text: 'Buy milk', done: false }])
15 
16// set(value) replaces the state (optimistic locally, then synced)
17todos.set([{ id: '1', text: 'Buy milk', done: true }])

Open the same page in two tabs — changes sync in real-time.

React Hooks

The @better-state/react package provides a useState-like API for synced state.

BetterStateProvider

Wrap your app with the provider to initialize the client:

main.tsx
1import { BetterStateProvider } from '@better-state/react'
2 
3function App() {
4 return (
5 <BetterStateProvider
6 url="http://localhost:3001"
7 apiKey="your-api-key"
8 >
9 <MyApp />
10 </BetterStateProvider>
11 )
12}

useBetterState(key, initialValue)

Returns [value, set, update] — just like useState, but synced across every connected client.

Counter.tsx
1import { useBetterState } from '@better-state/react'
2 
3function Counter() {
4 const [count, setCount, updateCount] = useBetterState('counter', 0)
5 
6 return (
7 <button onClick={() => updateCount(n => n + 1)}>
8 Count: {count}
9 </button>
10 )
11}

useConnectionStatus()

Returns the current connection status: 'connecting' | 'connected' | 'disconnected'.

StatusBanner.tsx
1import { useConnectionStatus } from '@better-state/react'
2 
3function StatusBanner() {
4 const status = useConnectionStatus()
5 
6 if (status === 'connected') return null
7 return <div>Status: {status}</div>
8}

Client API Reference

createClient(url, options)

Creates a Better-State client and connects to the server.

const client = createClient('http://localhost:3001', {
apiKey: string, // required — your namespace API key
namespace?: string, // default: 'default'
debug?: boolean, // default: false — log protocol events
})

client.state(key, initialValue)

Creates or retrieves a synced state object. If the key already exists on the server, the server's value replaces the initial value after connection.

const counter = client.state('counter', 0)
const todos = client.state('todos', [])
const profile = client.state('profile', { name: '', theme: 'light' })

state.get()

Returns the current local value synchronously.

const value = counter.get() // 0

state.set(value)

Replaces the state with a new value. Applies locally first (optimistic), then syncs to the server. Recommended for most use cases.

1todos.set([
2 { id: '1', text: 'Buy milk', done: true },
3 { id: '2', text: 'Walk dog', done: false },
4])

state.update(fn)

Applies a transformation function locally (optimistic), then syncs the resulting change to the server as a safe operation. In production apps, prefer explicit operations (like put, inc, and array ops) when you need precise conflict behavior.

counter.update(n => n + 1)
todos.update(list => list.filter(t => !t.done))

state.subscribe(fn)

Fires on every state change (local and remote). Fires immediately with the current value. Returns an unsubscribe function.

1const unsub = counter.subscribe(value => {
2 document.getElementById('count').textContent = String(value)
3})
4 
5unsub() // stop listening

client.status()

Returns 'connecting' | 'connected' | 'disconnected'.

client.onStatusChange(fn)

client.onStatusChange(status => {
if (status === 'disconnected') showOfflineBanner()
if (status === 'connected') hideOfflineBanner()
})

client.onError(fn)

Error codes include transport-level errors (CONNECT_ERROR, AUTH_ERROR) and mutation errors like bad_request, conflict, forbidden, internal_error.

client.onError(err => {
console.error(`[${err.code}] ${err.message}`)
})

client.disconnect()

Disconnects from the server and cleans up all listeners.

Server API

REST Endpoints

MethodEndpointDescription
GET/api/v1/healthHealth check
GET/api/v1/namespacesList namespaces
POST/api/v1/namespacesCreate namespace (returns API key)
GET/api/v1/statesList state objects
GET/api/v1/states/:key/historyMutation history (paginated)

Environment Variables

VariableDefaultDescription
PORT3001Server port
CORS_ORIGIN*Allowed origins
DATABASE_PATH.better-state/state.dbSQLite database path
POLICY_MODEpermissivePolicy mode: permissive | strict
POLICY_PATH(unset)Path to policy JSON file (recommended for production)
MAX_KEY_LENGTH200Max key length for mutate requests
MAX_MUTATIONS_PER_BATCH100Max mutations per mutate request
MAX_WS_JSON_BYTES256kbMax mutate payload size over WebSocket/Socket.IO
MAX_HTTP_JSON_BYTES256kbMax JSON payload size for REST endpoints
LOG_LEVELinfoLog level: debug | info | warn | error
LOG_PRETTYfalsePretty-print logs when true/1
STUDIO_PASSWORD(unset)Protect Studio endpoints when set

Self-hosting

npm install -g @better-state/server
PORT=3001 better-state

Protocol & Guarantees

Better-State synchronizes JSON documents per key using a durable event log and an authoritative snapshot (states.snapshot). Clients sync over WebSocket/Socket.IO.

For the exact WebSocket event names, payload shapes, ack/error contracts, and limits, see the full spec: Protocol Specification.

Guarantees

  1. Idempotency — each mutation must include a unique id. Retrying the same id will not double-apply effects (server dedupes).
  2. Ordering — server establishes authoritative ordering using (server_ts, seq).
  3. Optimistic Concurrency — clients send baseVersion; server rejects conflicts with code: 'conflict'.
  4. Policy enforcement — server can reject ops based on key/op/path rules (strict or permissive mode).

Operations (Mutations)

Mutations are operation-based (no server-side code execution). Each mutation envelope includes an op describing a safe, deterministic change.

Mutation envelope

mutate payload (shape)
1{
2 key: string,
3 baseVersion?: number,
4 mutations: Array<{
5 id: string,
6 clientTs: number,
7 op: JsonOp,
8 value?: unknown
9 }>
10}

Common operation types

JsonOp (examples)
1// replace entire document
2{ type: "set", value: anyJsonValue }
3 
4// write a value at a path (JSON pointer-like)
5{ type: "put", path: "/cards/abc/title", value: "New title" }
6 
7// increment a number at a path
8{ type: "inc", path: "/counter", by: 1 }
9 
10// array helpers
11{ type: "arrayInsert", path: "/items", value: "A" }
12{ type: "arrayRemove", path: "/items", index: 0 }
13{ type: "arrayMove", path: "/items", from: 2, to: 0 }

Paths use a conservative pointer-like format (e.g. /cards/abc/title).

Concurrency & Conflicts

Clients include baseVersion on mutate requests. If the server's current version differs, it rejects with code: 'conflict'.

Conflict error payload

mutate:error (conflict)
1{
2 code: "conflict",
3 message: "Version conflict",
4 key: string,
5 baseVersion: number,
6 serverVersion: number
7}

Recommended client behavior

On conflict: resubscribe to receive the latest state:init/state:update, then retry pending mutations with the new version.

Policy (Authorization)

Policy is enforced on the server before persisting events. This prevents clients from bypassing product-level role checks via devtools or custom scripts.

Mode

  • POLICY_MODE=permissive (default): if no rule matches a key, mutations are allowed.
  • POLICY_MODE=strict: if no rule matches a key, mutations are rejected (forbidden).

Policy file example

policy.json
1{
2 "mode": "strict",
3 "rules": [
4 {
5 "name": "syncboard boards",
6 "keyPrefix": "syncboard:board:",
7 "allowOps": ["put", "arrayInsert", "arrayRemove", "arrayMove", "arraySplice"],
8 "allowPaths": ["/*"],
9 "denyPaths": ["/members/*", "/adminSettings/*"],
10 "limits": { "maxMutationsPerBatch": 50 }
11 }
12 ]
13}

Limits & Safety

Better-State enforces global ingress limits to protect the server from accidental or abusive payloads. These limits apply regardless of policy mode.

  • MAX_KEY_LENGTH — rejects overly long keys
  • MAX_MUTATIONS_PER_BATCH — rejects large mutation batches
  • MAX_WS_JSON_BYTES — rejects oversized WebSocket mutate payloads
  • MAX_HTTP_JSON_BYTES — caps REST JSON request size

Observability

The server emits structured logs and maintains lightweight in-memory metrics counters and timings.

Metrics endpoint (Studio)

If Studio is enabled, you can fetch a metrics snapshot:GET /api/v1/studio/metrics

Useful counters

ws.mutate
ws.mutate_error{code="bad_request"|"conflict"|"forbidden"|"internal_error"}
ws.policy_reject
ws.conflict
ws.duplicate_mutations
ws.broadcast

How It Works

  1. Client applies locally first — UI updates instantly (optimistic)
  2. Mutation sent to server via WebSocket
  3. Server appends to event log and updates the authoritative snapshot
  4. Server broadcasts the authoritative state to all subscribed clients
  5. Clients replace their local value with the server's version

Conflict Resolution

Better-State uses optimistic concurrency via baseVersion. If the server detects a version mismatch, it rejects the mutation batch with code: 'conflict'.