The core idea: separate read and write models
In LiveStore, you never mutate state directly. Instead, you commit immutable events that describe what happened. Materializers then translate those events into rows in your local SQLite database, which forms the read model.- The event log is the source of truth — you can always rebuild the read model by replaying events
- Multiple read model shapes can be derived from the same events
- The event log is portable: sync it to another device and replay to produce identical state
Designing events
Events describe what happened, not what to do. This distinction matters: an imperative command (setUserName) is harder to reason about across time than a fact (userNameChanged).
Event naming conventions
Use past-tense, domain-language names for events:What to put in event payloads
Include the minimum data needed for the materializer to update state correctly. Avoid derived or computed values in event payloads — compute them in the materializer or at query time instead.Version your event names from the start
Always include a version prefix in event names (v1., v2., …). This makes schema evolution explicit and avoids ambiguity when you need to change an event shape later.
Designing state tables
State tables are the SQLite read model. They are derived from events and can be changed freely as long as the materializers are updated to match.Basic table definition
Writing materializers
Materializers translate events into table operations. They must be deterministic — the same event must always produce the same mutations.Soft deletes vs hard deletes
AvoidDELETE events. When you delete a row by committing a delete event, information is permanently lost from the state model. If you need to add a “restore” feature later, there is nothing to restore.
Instead, use soft deletes: add a deletedAt or isDeleted column to your table and set it in a materializer.
- A recoverable history — undo deletes by clearing
deletedAt - A complete audit trail
- Compatibility with sync: a delete event from one client doesn’t conflict with an in-progress edit from another
List ordering patterns
When users can reorder items (drag-and-drop, manual sorting), use fractional indexing to store order positions. Traditional integer ordering (1, 2, 3) requires renumbering multiple items on reorder, which creates conflicts in distributed systems. Fractional indexing uses string-based positions that always allow insertion between any two existing positions.Always use standard string comparison for fractional index ordering. Avoid
String.prototype.localeCompare(), which may produce incorrect sort order.When to split into multiple containers
A container is an isolated store instance with its own event log and state tables. There is no transactional consistency between containers. Split into multiple containers when you need:Data isolation and access control
Data isolation and access control
If different users or roles should only see a subset of data, put that data in separate containers. Sync access can then be enforced per container at the backend.
Scalability
Scalability
A single container’s event log grows over time. If you have high-frequency events that don’t need to coexist with lower-frequency events (e.g., real-time cursor positions vs. document edits), split them so each can be pruned or scaled independently.
Multi-tenant applications
Multi-tenant applications
In a SaaS app, each workspace or tenant typically maps to a separate container. This ensures one tenant’s data is never visible to another.
Separating local-only from synced state
Separating local-only from synced state
UI state (collapsed panels, selected rows, scroll positions) that should never leave the device can live in a local-only container while the shared domain data lives in a synced container.
Schema evolution
Evolving state tables
State table changes (adding columns, renaming, changing defaults) are generally safe as long as you update the materializers to match. Because the read model is derived from the event log, you can wipe and rebuild the state tables at any time.Evolving event schemas
Event schema changes require more care because old events already committed to the log must remain readable. Safe changes:- Adding an optional field with a default value
- Adding a new event type
- Removing a required field
- Changing a field’s type
- Renaming an event
Handling unknown events during app evolution
When you deploy a new app version with new event types, older clients may see events they don’t recognize. ConfigureunknownEventHandling in your schema to control this behavior:
| Strategy | Behavior |
|---|---|
'warn' | Log and continue (default) |
'ignore' | Silently skip unknown events |
'fail' | Throw an error — useful during development |
'callback' | Forward to your telemetry and continue |
App evolution patterns
As your app grows, you may need to change how data is structured beyond a simple schema update. Common scenarios:Adding a new concept
Example: Your app has workspaces. You want to add projects inside each workspace, and pre-populate a default project for each existing workspace. Approach: Add a new event (defaultProjectCreated) and emit it for each existing workspace as a migration event during app startup. The materializer creates the default project rows. Because events are idempotent by ID, replaying the log won’t duplicate the projects.
Renaming or splitting a concept
When a concept changes significantly, create new events describing the new model and write a one-time migration that reads the old state and commits events describing the new structure. The old events remain in the log but the new events overlay the state.Data backfill
Add a migration that runs once (keyed by a migration ID in a local table) and commits events to populate new fields or tables. Check the migration ID on startup before committing.Common modeling mistakes to avoid
Putting mutable IDs in events
Putting mutable IDs in events
Never use mutable values (user-supplied names, timestamps without a stable ID) as primary identifiers in events. Use UUID or nanoid-generated IDs instead, and include them in the event payload.
Command-style event names
Command-style event names
Event names like
setColor or updateUser suggest mutation rather than a fact. Use past-tense names that describe what happened: colorChanged, userProfileUpdated.Putting UI state in synced events
Putting UI state in synced events
Scroll positions, panel open/closed state, and hover state don’t belong in synced events. Use local-only events or a separate local container for transient UI state.
Skipping event versioning
Skipping event versioning
Without a version prefix in event names, you can’t safely add a new version of an event. Add
v1. prefixes from the start.Side effects in materializers
Side effects in materializers
Materializers must be pure. Any side effect (API calls, logging, random values) will run on every replay of the event log. Move side effects to event handlers or to the application layer that commits events.
Overloaded events
Overloaded events
Avoid events that update many unrelated fields at once (e.g.,
userUpdated that changes name, avatar, and preferences simultaneously). Separate concerns into distinct events so each change has a clear meaning in the history.