@firsttx/local-first
v0.11.0
Published
React + IndexedDB data layer that exposes synchronous snapshots, staleness metadata, and server sync hooks while pairing with Tx for optimistic durability.
Downloads
852
Maintainers
Readme
@firsttx/local-first
Bridge IndexedDB (async) and React state (sync) seamlessly.
Type-safe IndexedDB models with React integration via useSyncExternalStore. Eliminates 90% of server sync boilerplate. Works with @firsttx/tx for atomic optimistic updates.
Why Local-First?
The only IndexedDB library with synchronous React API + automatic multi-tab sync.
- Sync API:
getCachedSnapshot()returns instantly (no async/await in render) - Multi-tab sync: BroadcastChannel-based real-time sync across tabs (~1ms latency)
- TTL metadata: Automatic staleness detection with
history.isStale - Zod validation: Type-safe schema with runtime validation
- Zero boilerplate: 3 lines to define a persistent model
npm install @firsttx/local-first zodBasic Usage
1. Define a Model
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(),
price: z.number(),
qty: z.number(),
}),
),
total: z.number(),
}),
ttl: 5 * 60 * 1000, // optional - defaults to 5 minutes
initialData: { items: [], total: 0 },
});2. Use in React (Local-Only)
import { useModel } from '@firsttx/local-first';
function CartPage() {
const { data: cart, patch, history, status } = useModel(CartModel);
if (status === 'loading') return <Skeleton />;
if (status === 'error') return <ErrorMessage />;
if (!cart) return <EmptyCart />;
return (
<div>
{history.isStale && <Badge>Stale ({Math.floor(history.age / 1000)}s old)</Badge>}
<h1>Cart ({cart.items.length} items)</h1>
<button
onClick={() =>
patch((draft) => {
draft.items.push({ id: '1', name: 'Product', price: 100, qty: 1 });
draft.total += 100;
})
}
>
Add Item
</button>
</div>
);
}3. Sync with Server (Auto)
import { useSyncedModel } from '@firsttx/local-first';
function CartPage() {
const {
data: cart,
patch,
sync,
isSyncing,
error,
} = useSyncedModel(
CartModel,
async () => {
const res = await fetch('/api/cart');
return res.json();
},
{
syncOnMount: 'stale', // auto-sync when stale (default)
onSuccess: () => toast.success('Synced'),
onError: (err) => toast.error(err.message),
},
);
if (!cart) return <Skeleton />;
return (
<div>
{isSyncing && <Spinner />}
<button onClick={() => sync()}>Refresh</button>
{/* ... */}
</div>
);
}API Reference
defineModel(key, options)
Defines a type-safe IndexedDB model with automatic React integration.
const Model = defineModel('cart', {
schema: z.object({ items: z.array(...) }),
ttl: 5 * 60 * 1000,
version: 1,
initialData: { items: [] },
merge: (current, incoming) => ({ ...current, ...incoming }),
});Parameters
key: string- Unique IndexedDB key for this modeloptions.schema: ZodSchema- Zod schema for validationoptions.ttl?: number- Time-to-live in milliseconds (default:5 * 60 * 1000= 5 minutes)- Set to
Infinityfor data that never expires - Set to
0for always-stale behavior
- Set to
options.version?: number- Schema version. When changed, existing data is deletedoptions.initialData?: T- Default value when no data exists. Required forpatch()options.merge?: (current: T, incoming: T) => T- Custom conflict resolution for cross-tab sync
Returns Model<T>
Model Properties
name: string- Model keyschema: ZodSchema- Validation schemattl: number- Effective TTL valuemerge: (current, incoming) => T- Conflict resolver
Model Methods
getSnapshot(): Promise<T | null>- Load data from IndexedDBgetHistory(): Promise<ModelHistory>- Get metadata (age, staleness)replace(data: T): Promise<void>- Replace entire data (includingnull)patch(mutator: (draft: T) => void): Promise<void>- Update existing data via Immer-style draft mutationsubscribe(callback: () => void): () => void- Listen to changesgetCachedSnapshot(): T | null- Synchronous cached readgetCachedHistory(): ModelHistory- Synchronous cached metadatagetCachedError(): FirstTxError | null- Get last error
When to use patch() vs replace()
Use patch() when you have existing data and want to modify specific fields:
await Model.patch((draft) => {
draft.items.push(newItem); // Mutate draft in place
draft.total += 100; // No return statement
});Use replace() when you want to completely replace data (including setting/clearing null):
// Set initial data
await Model.replace({ items: [], total: 0 });
// Clear data
await Model.replace(null);⚠️ Important: patch() requires existing data. If data is null and you haven't provided initialData, patch() will throw an error. Use replace() instead.
useModel(model)
React hook for local-only model usage (no server sync).
const { data, status, patch, history, error } = useModel(CartModel);Parameters
model: Model<T>- Model created withdefineModel
Returns { data, status, patch, history, error }
data: T | null- Current data (null while loading or empty)status: 'loading' | 'success' | 'error'- Current loading statuspatch: (mutator: (draft: T) => void) => Promise<void>- Update function (Immer-style)await patch((draft) => { draft.items.push(newItem); });history: ModelHistory- MetadataupdatedAt: number- Unix timestamp of last updateage: number- Time elapsed since last update (ms)isStale: boolean- Whetherage > ttlisConflicted: boolean- Whether cross-tab conflict occurred
error: FirstTxError | null- Validation or storage error
Example
const { data: cart, status, patch, history } = useModel(CartModel);
if (status === 'loading') return <Skeleton />;
if (status === 'error') return <ErrorMessage />;
if (!cart) return <EmptyCart />;
return (
<div>
{history.isStale && <Badge>Data is {Math.floor(history.age / 1000)}s old</Badge>}
<button onClick={() => patch((draft) => draft.items.push(item))}>Add Item</button>
</div>
);useSyncedModel(model, fetcher, options?)
React hook for model with automatic server synchronization.
const { data, status, patch, sync, isSyncing, error, history } = useSyncedModel(
CartModel,
fetchCart,
{
syncOnMount: 'stale',
onSuccess: (data) => console.log('Synced', data),
onError: (err) => console.error(err),
},
);useSuspenseSyncedModel(model, fetcher)
React 18.2+ supported. Suspense-enabled hook for declarative data fetching with automatic IndexedDB cache.
import { useSuspenseSyncedModel } from '@firsttx/local-first';
function ContactsList() {
const contacts = useSuspenseSyncedModel(ContactsModel, fetchContacts);
return (
<div>
{contacts.map((c) => (
<ContactCard key={c.id} {...c} />
))}
</div>
);
}
function App() {
return (
<ErrorBoundary fallback={<ErrorAlert />}>
<Suspense fallback={<Skeleton />}>
<ContactsList />
</Suspense>
</ErrorBoundary>
);
}Parameters
model: Model<T>- Model created withdefineModelfetcher: (current: T | null) => Promise<T>- Async data fetcher
Returns T
- Direct data (never
null) - Throws Promise for Suspense on initial load
- Throws Error for Error Boundary on fetch failure
Cache-First Behavior
useSuspenseSyncedModel implements a stale-while-revalidate pattern:
- First visit: No cache → fetch from network → show Suspense fallback
- Revisit (fresh cache): Return cached data instantly → no network request
- Revisit (stale cache): Return cached data instantly → revalidate in background
This eliminates blank screens on page refresh, providing SSR-level UX without SSR.
Key Differences from useSyncedModel
| Feature | useSyncedModel | useSuspenseSyncedModel |
| -------------- | -------------------------- | ------------------------- |
| Return type | { data: T \| null, ... } | T (never null) |
| Loading state | Manual if (isSyncing) | Automatic Suspense |
| Error handling | Manual if (error) | Automatic Error Boundary |
| Type safety | Nullable data | Non-nullable data |
| React version | 18.2+ | 18.2+ |
| Use case | Full control, mutations | Simple read-only fetching |
Requirements
- React 18.2+
- Must be wrapped in
<Suspense>boundary - Recommended: wrap in
<ErrorBoundary>
Performance Benefits
Compared to traditional fetch-on-render approaches:
- 50% faster on revisits: Single IndexedDB read (data + history combined)
- Zero network requests: Fresh cached data skips fetcher entirely
- Background updates: Stale data updates silently without blocking UI
Limitations
- Read-only (use
useSyncedModelforpatch()or mutations) - Not suitable for SSR (client-side only)
- Background revalidation has no UI feedback (use
useSyncedModelif you needisSyncingstate)
Example: Dashboard with Multiple Models
function Dashboard() {
return (
<ErrorBoundary fallback={<ErrorAlert />}>
<Suspense fallback={<DashboardSkeleton />}>
<StatsCards />
<RecentActivity />
</Suspense>
</ErrorBoundary>
);
}
function StatsCards() {
const stats = useSuspenseSyncedModel(StatsModel, fetchStats);
const contacts = useSuspenseSyncedModel(ContactsModel, fetchContacts);
return (
<div>
<Card>Total: {stats.total}</Card>
<Card>Contacts: {contacts.length}</Card>
</div>
);
}When to use Suspense?
✅ Use useSuspenseSyncedModel when:
- Simple read-only data display
- Want declarative loading/error states
- Prefer less boilerplate
❌ Use useSyncedModel when:
- Need mutations (
patch(), manualsync()) - Want granular control over loading UI
- Building SSR apps
useSyncedModel(model, fetcher, options?) (continued)
Parameters
model: Model<T>- Model created withdefineModelfetcher: (current: T | null) => Promise<T>- Function to fetch server data- Receives current local data for delta sync support
- Should return full data to replace local state
options?: SyncOptionssyncOnMount?: 'always' | 'stale' | 'never'(default:'stale')'always': Always sync on component mount'stale': Only sync whenhistory.isStale === true'never': Never auto-sync, only manualsync()calls
onSuccess?: (data: T) => void- Called after successful synconError?: (error: Error) => void- Called on sync failure
Returns SyncedModelResult<T>
data: T | null- Current datapatch: (mutator: (draft: T) => void) => Promise<void>- Update existing data via draft mutationreplace: (data: T) => Promise<void>- Replace entire datasync: () => Promise<void>- Manual sync trigger- Safe to call multiple times (automatically deduplicated)
- Uses ViewTransition for smooth updates (if available)
isSyncing: boolean- Whether sync is in progresserror: Error | null- Sync error (not validation errors)history: ModelHistory- Metadata (same asuseModel)
Example
const {
data: cart,
sync,
isSyncing,
} = useSyncedModel(
CartModel,
async (current) => {
// Delta sync example
const since = current ? new Date(current.lastSync) : null;
const res = await fetch(`/api/cart?since=${since}`);
return res.json();
},
{ syncOnMount: 'stale' },
);
return (
<div>
<button onClick={() => sync()} disabled={isSyncing}>
{isSyncing ? 'Syncing...' : 'Refresh'}
</button>
{/* ... */}
</div>
);Sync Behavior
Mount → Load IndexedDB → Check isStale
↓
if syncOnMount === 'always' → sync()
if syncOnMount === 'stale' && isStale → sync()
if syncOnMount === 'never' → do nothingFeatures
Cross-Tab Synchronization
Automatically syncs changes across all open tabs using BroadcastChannel API.
// Tab 1
await CartModel.patch((draft) => draft.items.push(item));
// Tab 2 (instantly receives update via BroadcastChannel)
// React re-renders with new data (~1ms latency)How it works
- Every
patch()orreplace()broadcasts to other tabs - Tabs auto-reload from IndexedDB on receiving broadcast
- Custom
merge()function resolves conflicts - Zero network overhead (browser-internal communication)
- Graceful degradation (97%+ browser support)
Conflict Resolution
const Model = defineModel('cart', {
schema: CartSchema,
merge: (current, incoming) => {
// Custom merge logic
return {
items: [...current.items, ...incoming.items].filter(uniqueById),
total: recalculate(merged.items),
};
},
});TTL-Based Staleness
Data automatically expires based on TTL, triggering smart refetches.
const Model = defineModel('prices', {
schema: PriceSchema,
ttl: 30 * 1000, // 30 seconds
});
const { data, history } = useSyncedModel(Model, fetchPrices, {
syncOnMount: 'stale', // refetch when age > 30s
});
// Visual feedback
{
history.isStale && <Badge variant="warning">Prices may be outdated</Badge>;
}TTL Use Cases
30s - 5min: Real-time data (stock prices, live scores)5min - 1hr: Frequently updated (product inventory, user notifications)1hr - 24hr: Slow-changing (user profile, settings)Infinity: Static content (translations, constants)
Schema Validation
Zod schema protects against corrupted IndexedDB data.
const Model = defineModel('cart', {
schema: z.object({
items: z.array(ItemSchema),
total: z.number().nonnegative(),
}),
});
// Invalid data is rejected
await Model.replace({ items: [], total: -100 }); // ❌ ValidationErrorError Handling
const [data, patch, history, error] = useModel(Model);
if (error) {
return <ErrorBanner error={error} onReset={() => Model.replace(initialData)} />;
}Advanced
Schema Migrations
const UserModel = defineModel('user', {
schema: z.object({
id: z.string(),
name: z.string(),
email: z.string(), // added in v2
}),
version: 2,
initialData: { id: '', name: '', email: '' },
});
// On version mismatch:
// 1. Old data is deleted
// 2. initialData is written
// 3. Next sync fetches fresh dataError Types
import { FirstTxError, StorageError, ValidationError } from '@firsttx/local-first';
try {
await Model.replace(data);
} catch (error) {
if (error instanceof ValidationError) {
console.error('Schema validation failed:', error.zodError);
} else if (error instanceof StorageError) {
console.error('IndexedDB error:', error.code, error.context);
}
}Related Packages
@firsttx/tx- Atomic transactions for optimistic updates@firsttx/prepaint- Instant page restoration
License
MIT © joseph0926
