Skip to content
Draft Store
AutoXXS (320px)XS (375px)SM (640px)MD (768px)LG (1024px)XL (1280px)XXL (1536px)
SketchMaterialiOSTamagui
DataInjectionKeyPatternsServiceTransactionProcessResearchProductQualityPerformanceSpecDomainFunctionTechnologyArchitectureConfigMiddlewareDataDatabaseDrizzleMigrationModelop-sqliteSchemaSQLState ManagementDraftKeystoneMergePatchPatchesPersistenceReactiveRedoStoreUndoTestingDeviceFactoryIsolationTypeScriptZodTopicsCommunicationBidsNVCDesignDesign ImplicationsEducationPedagogyFoundationsPsychologyAttachmentFloodingRelatingAuthentic RelatingUIEditorReact Native

Draft Store

module

A generic, persistent draft mechanism for in-flight edits. Drafts are ephemeral UI state keyed by type and domain entity ID, persisted as JSON blobs for crash recovery. The draft commit is the action boundary — domain functions receive the target model as first argument and the draft as data payload, producing one undo entry and one SQL transaction per save.

Draft State Is UI State

Draft state does not belong in SQLite domain tables or in persisted domain models. It is transient editor state with a different lifecycle: created when the user begins editing, mutated freely with reactive bindings on every keystroke, and discarded or committed when the user saves. Keeping it separate makes both layers cleaner — persisted models stay as faithful DB projections, and drafts can carry editor-specific validation, field coercion, and computed helpers without polluting the domain model.

Rule Force Realm Reference Description
no-domain-persistence-for-drafts must global Draft models must never write directly to domain SQLite tables. Drafts are committed through domain functions which validate and persist via the service or action layer.

Type/ID Key Encoding

Every draft is keyed by a composite string encoding the domain type and entity ID it maps to. This enables a single generic DraftStore to manage drafts across all entity types without per-type stores.

Key format: \{type\}:\{id\} for editing existing entities, \{type\}:new:\{uuid\} for creating new entities.

Draft key encoding examples
DraftKey draft-key-format
todo:abc-123 -> editing existing todo with id abc-123 todo:new:def-456 -> creating a new todo (draft id def-456) project:xyz-789 -> editing existing project with id xyz-789

Draft Schema Derivation

Draft Zod schemas are the one place hand-written Zod is acceptable (since drafts are not database tables). However, they should derive from the domain insert schema to avoid structural drift. When a column is added to the Drizzle table, the insert schema updates, and the draft schema inherits the change automatically unless explicitly omitted.

Draft schema derived from domain insert schema
ZodDraftSchema derived-draft-schema
export const TodoDraftSchema = TodoInsertSchema
.omit({ id: true, createdAt: true }) // generated fields
.extend({
refId: z.string().nullable(), // draft-specific metadata
})
.partial() // all fields optional while editing
.extend({
title: z.string().default(''), // override: empty string, not missing
})
const TodoDraft = modelFromZod('TodoDraft', TodoDraftSchema)

Commit as Action Boundary

The draft commit is the granularity boundary for undo history and SQL persistence. Individual keystrokes mutate the draft freely (no patches recorded against the domain model, no SQL). Only when the user saves does a domain function validate the draft and apply it to the domain model in a single @modelAction. This produces one patch group, one undo entry, and one SQL transaction.

User edits fields
-> draft.title = "new title" (no patches recorded, no SQL)
-> draft.completedAt = null (no patches recorded, no SQL)
User presses Save
-> commitTodoEdit(todo, draft) <- domain logic + validation
-> todo.applyDraft(draft) <- @modelAction, patches recorded
patch: replace title
patch: replace completedAt
patch: replace updatedAt
actionTrackingMiddleware.onFinish fires
-> all patches grouped
-> one SQL transaction
undoManager records one UndoEvent
-> undo() reverts entire draft commit atomically

Domain Functions (Command)

Domain logic is expressed as plain functions (not model methods) that always receive the target domain object as first argument and the draft (or other arguments) as subsequent parameters. This keeps models thin (data + mutation primitives only), domain logic pure and testable, and decouples validation from the model class.

Rule Force Realm Reference Description
first-arg-is-target should global Domain functions should receive the domain model or store as their first argument and the draft or data payload as subsequent arguments. This convention enables consistent composition and testing.
Domain functions with model-first convention
DomainFunction domain-function-convention
// Domain functions — pure, no class needed
// First argument is always the domain object (or store for creation)
function commitTodoEdit(todo: Todo, draft: TodoDraft): void {
if (!draft.isValid) throw new Error('Title required')
if (todo.completedAt && !draft.completedAt) {
throw new Error('Cannot un-complete a todo')
}
// single @modelAction — one patch group, one undo entry, one SQL transaction
todo.applyDraft(draft)
}
function commitTodoCreate(store: TodoStore, draft: TodoDraft): void {
if (!draft.isValid) throw new Error('Title required')
store.addTodo(Todo.fromDraft(draft))
}
// Namespace for discoverability
const TodoDomain = {
edit: (todo: Todo, draft: TodoDraft) => { /* ... */ },
complete: (todo: Todo) => { /* ... */ },
block: (todo: Todo, blockedBy: Todo) => { /* ... */ },
}
const TodoStoreDomain = {
create: (store: TodoStore, draft: TodoDraft) => { /* ... */ },
reorder: (store: TodoStore, ids: string[]) => { /* ... */ },
}

Architecture

architecture Draft Store architecture
Gallery

Components

store

draft-model

MobX Keystone

A generic model with type/id key encoding, arbitrary data payload, and timestamps. Carries metadata about whether it represents a new entity or an edit of an existing one. Provides computed helpers for validation and change detection.

Generic Draft model with key encoding
DraftModelKeystone draft-model-definition
@model('Draft')
class Draft extends Model({
key: prop<string>(), // "todo:123" | "todo:new:abc"
type: prop<string>(), // "todo"
refId: prop<string | null>(null), // null = new entity
data: prop<Record<string, unknown>>(),
createdAt: prop<number>(),
updatedAt: prop<number>(),
}) {
@computed get isNew() {
return this.refId === null
}
}
store

draft-store

MobX Keystone

Manages the lifecycle of all active drafts. Provides upsert, get, and discard operations. Persists the entire draft collection as a JSON blob in a SQLite key-value table with debounced writes. Supports hydration on app restart for crash recovery.

Collaborates with
  • draft-model — Holds a collection of Draft model instances.
DraftStore with debounced KV persistence
DraftStorePersistence draft-store-implementation
@model('DraftStore')
class DraftStore extends Model({
drafts: prop<Draft[]>(() => []),
}) {
@modelAction
upsertDraft(key: string, data: Record<string, unknown>) {
const existing = this.drafts.find(d => d.key === key)
if (existing) {
existing.data = data
existing.updatedAt = Date.now()
} else {
const [type, maybeNew, ...rest] = key.split(':')
this.drafts.push(new Draft({
key,
type,
refId: maybeNew === 'new' ? null : maybeNew,
data,
createdAt: Date.now(),
updatedAt: Date.now(),
}))
}
this.persist()
}
getDraft(key: string) {
return this.drafts.find(d => d.key === key) ?? null
}
@modelAction
discardDraft(key: string) {
this.drafts = this.drafts.filter(d => d.key !== key)
this.persist()
}
@computed get pendingDrafts() {
return this.drafts.filter(d => d.isNew)
}
@computed get unsavedEdits() {
return this.drafts.filter(d => !d.isNew)
}
persist = debounce(() => {
db.execute(
'INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)',
['drafts', JSON.stringify(getSnapshot(this))]
)
}, 500)
static async load(): Promise<DraftStore> {
const row = await db.execute('SELECT value FROM kv WHERE key = ?', ['drafts'])
const snapshot = row.rows[0]?.value
return snapshot
? fromSnapshot<DraftStore>(JSON.parse(snapshot))
: new DraftStore({})
}
}
store

draft-aware-model

MobX Keystone

Domain models that participate in draft-based editing expose a single model action as their write surface. This action is the only mutation point called by domain functions, ensuring all field changes within one save produce a single patch group.

Domain model with applyDraft write surface
KeystoneModelDraft apply-draft-action
@model('Todo')
class Todo extends Model({
id: prop<string>(),
title: prop<string>(),
completedAt: prop<number | null>(null),
updatedAt: prop<number>(),
}) {
@computed get isCompleted() {
return this.completedAt !== null
}
// Single write surface — called by domain functions
@modelAction
applyDraft(draft: TodoDraft) {
this.title = draft.title
this.completedAt = draft.completedAt
this.updatedAt = Date.now()
}
static fromDraft(draft: TodoDraft): Todo {
return new Todo({
id: generateId(),
title: draft.title,
completedAt: draft.completedAt,
updatedAt: Date.now(),
})
}
}
surface

editor-binding

React Native

Editor components bind to the draft model (not the persisted domain model). The draft is created from the domain entity on mount, mutated freely via two-way bindings, and committed via the domain function on save. Draft lifetime is managed via component state, a volatile on a screen model, or the global DraftStore for crash recovery.

Collaborates with
  • draft-store — Retrieves or creates drafts by key.
  • draft-aware-model — Commits drafts via domain functions targeting the domain model.
Editor component binding to draft
React NativeEditorDraft editor-component
const EditTodoScreen = observer(({ todoId }) => {
const todo = store.getTodo(todoId)
const draftKey = `todo:${todoId}`
// Restore or create draft
const [draft] = useState(() =>
draftStore.getDraft(draftKey)
?? draftStore.createDraft(draftKey, TodoDraft.fromTodo(todo))
)
return (
<>
<TextInput
value={draft.title}
onChangeText={action(v => draft.title = v)}
/>
<Button
disabled={!draft.isValid}
onPress={() => {
commitTodoEdit(todo, draft)
draftStore.discardDraft(draftKey)
}}
/>
</>
)
})

Rules

Rule Force Realm Reference Description
no-domain-persistence-for-drafts must global Draft models must never write directly to domain SQLite tables. Drafts are committed through domain functions which validate and persist via the service or action layer.
first-arg-is-target should global Domain functions should receive the domain model or store as their first argument and the draft or data payload as subsequent arguments. This convention enables consistent composition and testing.
draft-recovery should global Drafts representing new entity creation should be persisted to the DraftStore for crash recovery. Drafts for editing existing entities may use component-local state if recovery is not critical.
discard-on-commit must global Drafts must be discarded from the DraftStore after successful commit to prevent stale drafts from resuracing on next app launch.