Local-First
Local-First is a package for building an “offline-durable data layer”
on top of IndexedDB + a synchronous React store + Zod schemas.
- Uses IndexedDB as the single source of truth
- Ensures type and integrity with Zod schemas
- Comes with TTL, versioning, multi-tab sync, server sync hooks, and Suspense integration built in.
- When you want to keep list/form state intact across refreshes and revisits
When you want to guarantee “the last state the user saw” even on offline/unstable networks
- When you need local durability for internal tools/dashboards but don’t want to deal with IndexedDB directly
1. Core concepts
Model
In Local-First, everything is organized around models.
// models/cart.ts
import { defineModel } from "@firsttx/local-first";
import { z } from "zod";
export const CartModel = defineModel("cart", {
schema: z.object({
items: z.array(
z.object({
id: z.string(),
name: z.string(),
qty: z.number(),
}),
),
}),
// Optional: default is 5 minutes (5 * 60 * 1000 ms)
ttl: 5 * 60 * 1000,
// Optional: bump version to reset if the data shape breaks
version: "1",
// Recommended when using version: initial value on disk
initialData: {
items: [],
},
});
-
name- Base for the IndexedDB key and the BroadcastChannel name (
firsttx:models).
- Base for the IndexedDB key and the BroadcastChannel name (
-
schema- Zod schema. Always used to validate before/after storing/patching.
-
ttl- Time (ms) before the cache is considered “stale”. Default: 5 minutes.
-
version- Version flag to bump when the schema changes in a breaking way. If the version on disk differs, Local-First discards the old data and re-initializes.
-
initialData- Initial data to use when there is nothing on disk yet. When using
version, it’s effectively required so the model can reset to a sensible default.
- Initial data to use when there is nothing on disk yet. When using
defineModel(name, options)
| Name | Type | Default | Description |
|---|---|---|---|
schema | z.ZodType<T> | - | Zod schema describing the model structure. It is always validated before storing/patching. If validation fails, the stored data is deleted and a ValidationError is produced (in development it is thrown to help you catch issues early). |
ttl | number | 5 * 60 * 1000 | Time (ms) before the cache is considered expired. Once TTL has passed, history.isStale becomes true. The previous data is still shown first, and hooks like useSyncedModel can trigger a background resync. |
version | string | "1" | Version to bump when you make schema/shape changes that can break existing data. If the stored version differs, Local-First discards the old data and resets it to initialData or an empty state. When you use version, it’s strongly recommended to define initialData as well. |
initialData | T | null | null | Default data to use when there is no value on disk. If initialData is missing and you call patch, an error is thrown to prevent “mutating from null”. In most cases, you should provide initialData. |
merge | (previous: T, next: T) => T | (prev, next) => next | Merge function used when calling replace. By default, the new value simply replaces the old one. Note: this is only used by replace, not by patch. |
storageKey | string | name | Actual key used in IndexedDB. If omitted, the first argument to defineModel(name) is used. |
If Zod
schemavalidation fails, the corresponding key is deleted from IndexedDB, and DevTools records avalidation.errorevent.This usually indicates a mistake in schema/version changes, so in development Local-First throws
ValidationErrorto help you catch it early. In production, corrupted data is quietly removed and treated asnull.When you bump
version, defininginitialDatatogether allows a smooth transition from “broken data → new initial state”.
2. React hooks: useModel vs useSyncedModel
Local-First exposes two main hooks:
useModel(model)- subscribe only to the local model snapshot (no server sync)useSyncedModel(model, fetcher, options?)- local model + server sync
Both hooks internally use useSyncExternalStore, so they behave reliably in React 18/19.
2-1. useModel: when you only need local data
import { useModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
export function CartSidebar() {
const { data: cart, patch, history, error, status } = useModel(CartModel);
if (status === "loading") {
return <div className="text-xs text-muted-foreground">Loading...</div>;
}
if (status === "error") {
return (
<div className="text-xs text-destructive">
Failed to load cart: {error?.getUserMessage?.() ?? String(error)}
</div>
);
}
if (!cart) {
return <div className="text-xs text-muted-foreground">No cart yet.</div>;
}
return (
<ul className="space-y-1 text-sm">
{cart.items.map((item) => (
<li key={item.id} className="flex items-center justify-between">
<span>{item.name}</span>
<span className="text-muted-foreground">x {item.qty}</span>
</li>
))}
{history && (
<p className="mt-2 text-[11px] text-muted-foreground">
Last updated {Math.round(history.age / 1000)}s ago
{history.isStale && " · stale"}
</p>
)}
</ul>
);
}
useModelreturns the synchronous current snapshot from disk.- It does not talk to the server; it only reads whatever is already stored.
useModel(model) return value
| Name | Type | Default | Description |
|---|---|---|---|
data | T | null | - | Current model data. <code>null</code> if there is no value yet. |
status | 'loading' | 'success' | 'error' | - | Current loading status. Use this to distinguish between loading, success, and error states. |
patch | (mutator: (draft: T) => void) => Promise<void> | - | Function to update local data. It validates with Zod, persists to IndexedDB, and emits broadcast/DevTools events. |
history | { updatedAt: number | null; age: number; isStale: boolean } | - | Last update time and freshness information based on TTL. When there is no data, <code>updatedAt = null</code>, <code>age = Infinity</code>, <code>isStale = true</code>. |
error | FirstTxError | null | - | Error object when a storage or validation error occurred. <code>null</code> otherwise. |
2-2. useSyncedModel: when you want server sync as well
import { useSyncedModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
async function fetchCart(current: unknown) {
// current contains the currently cached value (or null).
const res = await fetch("/api/cart");
if (!res.ok) throw new Error("Failed to fetch cart");
return res.json();
}
export function CartPage() {
const {
data: cart,
isSyncing,
error,
history,
patch,
sync,
} = useSyncedModel(CartModel, fetchCart, {
syncOnMount: "stale", // "always" | "stale" | "never"
});
if (error) {
return (
<div className="text-sm text-destructive">
Failed to sync: {error.getUserMessage?.() ?? error.message}
</div>
);
}
if (!cart) {
return <div>Loading cart…</div>;
}
return (
<div className="space-y-4">
{cart.items.map((item) => (
<div key={item.id} className="flex items-center justify-between">
<span>{item.name}</span>
<span className="text-sm text-muted-foreground">x {item.qty}</span>
</div>
))}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{isSyncing && <span>Syncing latest data…</span>}
{history && (
<span>
Last updated {Math.round(history.age / 1000)}s ago
{history.isStale && " (stale)"}
</span>
)}
<button
type="button"
onClick={() => sync("manual")}
className="rounded-full border border-border px-2 py-0.5 text-[11px]"
>
Refresh
</button>
</div>
</div>
);
}
useSyncedModel(model, fetcher, options)
| Name | Type | Default | Description |
|---|---|---|---|
fetcher | (current: T | null) => Promise<T> | - | Function that fetches the latest data from the server. Receives the current cached value (or null) as its first argument. On success, Local-First calls replaceon the model with the new data. |
options.syncOnMount | "always" | "stale" | "never" | "always" | Strategy for automatic sync when the component mounts.
|
options.retry | RetryConfig | null | null | Retry strategy when the sync request fails. Uses the same shape as @firsttx/tx's retry config (maxAttempts, delayMs, backoff). If omitted, no retry is performed. |
options.onSuccess | (data: T) => void | - | Callback invoked when sync succeeds. |
options.onError | (error: FirstTxError) => void | - | Callback invoked when an error occurs during sync. DevTools also records a sync.error event. |
useSyncedModel(model, fetcher, options) return value
| Name | Type | Default | Description |
|---|---|---|---|
data | T | null | - | Current model data. <code>null</code> if there is no value yet. |
status | 'loading' | 'success' | 'error' | - | Current loading status from the underlying model. Use this to distinguish between loading, success, and error states. |
history | { updatedAt: number | null; age: number; isStale: boolean } | - | Last update time and freshness information based on TTL. |
error | FirstTxError | null | - | Error from the last sync attempt. Takes precedence over model-level errors. |
isSyncing | boolean | - | Whether a server sync is currently in progress. |
patch | typeof model.patch | - | Direct reference to the model's <code>patch</code> method. Use this for local optimistic updates. |
sync | (trigger?: 'mount' | 'manual') => Promise<void> | - | Function to manually trigger sync. Internally it deduplicates concurrent calls and records <code>sync.start</code>/<code>sync.success</code>/<code>sync.error</code> events in DevTools. |
Inside
useSyncedModel, asyncInProgressRefis used to track whether a sync is already running. Even if multiple components callsync()at the same time for the same model, only one network request is actually made.
3. Multi-tab sync & BroadcastChannel
When the same app is open in multiple tabs and data is modified, Local-First propagates changes through a ModelBroadcaster.
-
If the browser supports
BroadcastChannel:- It uses the
firsttx:modelschannel and sends messages likemodel-patched,model-replaced,model-deleted. - When you call
CartModel.patchin tab A, the snapshot in tab B’suseModel(CartModel)updates automatically.
- It uses the
-
In environments where
BroadcastChannelis not available:- A fallback broadcaster is used. It does not perform real cross-tab sync, but it records
broadcast.fallback/broadcast.skippedevents in DevTools. - In such environments, you may want to rely on page reloads or manual sync to keep data fresh.
- A fallback broadcaster is used. It does not perform real cross-tab sync, but it records
The current implementation exposes an
isConflictedflag, but it always remainsfalse. In other words, multi-tab contention does not trigger conflict detection yet.- If real-time conflict resolution is important for your domain, you should implement your own business rules on top, such as last-writer-wins or timestamp-based merge strategies.
4. Suspense integration
Local-First integrates nicely with React Suspense.
The easiest way is to use the useSuspenseSyncedModel hook.
import { Suspense } from "react";
import { useSuspenseSyncedModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
async function fetchCart() {
const res = await fetch("/api/cart");
if (!res.ok) throw new Error("Failed to fetch cart");
return res.json();
}
function CartInner() {
const { data: cart } = useSuspenseSyncedModel(CartModel, fetchCart);
return (
<ul className="space-y-2 text-sm">
{cart.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
export function SuspenseCart() {
return (
<Suspense fallback={<div>Loading cart…</div>}>
<CartInner />
</Suspense>
);
}
- Internally, this uses
model.getSyncPromise(fetcher). When there is no data and no error yet, it triggers a fetch and throws the Promise. - If the cache already has data, it simply returns and does not go through Suspense.
- If there is an error, it is thrown so that your ErrorBoundary can handle it.
If you need lower-level control, you can call getSyncPromise directly:
function CartInnerLowLevel() {
CartModel.getSyncPromise(fetchCart); // If there is no data, this throws a Promise and Suspense shows the fallback
const { data: cart } = useModel(CartModel);
if (!cart) return null;
return (
<ul className="space-y-2 text-sm">
{cart.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
5. DevTools integration
Local-First sends rich events to DevTools so you can understand what’s happening:
- Model initialization/load:
init,load - Data changes:
patch,replace - Revalidation/background sync:
revalidate,sync.start,sync.success,sync.error - Broadcast activity:
broadcast.sent,broadcast.received,broadcast.fallback,broadcast.skipped - Validation/storage errors:
validation.error,storage.error
Each event includes:
category: "model"type: one of the event types abovetimestamp: when it occurredpriority: 0-2 (e.g.sync.error/validation.errorhave higher priority)
In the DevTools panel, if you filter by the “model” category, you can quickly see:
- How frequently each model is being synced
- When ValidationError/StorageError events occurred
- Whether broadcasts are working (or falling back) as expected
6. Recommended patterns
Some practical patterns when designing with Local-First:
-
Separate data state from view state
- Instead of putting both view state and data state into Local-First models, manage only the server-backed data state as models.
- UI concerns like modal open/close or hover state are usually easier as plain React state.
-
Design TTL thoughtfully
- Start from a 5-minute TTL:
- For frequently read, rarely updated data → increase TTL to reduce network calls.
- For frequently updated, freshness-sensitive data → decrease TTL to sync more often.
- Start from a 5-minute TTL:
-
When used together with Tx
- Put
CartModel.patch(and other model updates) as the first step of a Tx transaction. - Put server sync as the next step.
- This way, if anything fails, rollback can cleanly revert Local-First models as well.
- Put
-
Understand the role of merge
mergeis only applied toreplacecalls, not topatch.- If you need partial merge logic, implement it explicitly inside
patch.
-
Plan for environments without BroadcastChannel
- In older browsers/special environments you may not get real-time cross-tab sync.
- In such cases, design UX around “refresh / manual sync” to keep data up to date.
-
Handle large data carefully
- For large models, consider TTL tuning, server-side filtering, and splitting models to keep JSON serialization and comparison costs reasonable.