한국어 버전은 docs/README.ko.md를 확인해주세요.
Eliminate blank screens on revisits - Restore last state instantly
❌ Before prepaint | ✅ After prepaint |
Slow 4G: Blank screen exposed | Slow 4G: Instant restore |
Have you experienced any of these?
- Users complaining "loading is too slow"
- Developing internal tools with frequent revisits
- Losing work progress on refresh
- Want SSR benefits while keeping CSR architecture
→ If any apply, FirstTx can help
For most cases (recommended)
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx
For specific features only
Revisit optimization only
pnpm add @firsttx/prepaint
Revisit + Data synchronization
pnpm add @firsttx/prepaint @firsttx/local-first
Data synchronization + Optimistic updates
pnpm add @firsttx/local-first @firsttx/tx
⚠️ Dependency Tx requires Local-First.
// vite.config.ts
import { firstTx } from '@firsttx/prepaint/plugin/vite';
export default defineConfig({
plugins: [firstTx()],
});
How does it work?
The Vite plugin automatically injects a boot script into HTML. This script instantly restores the saved screen from IndexedDB on page load.
// main.tsx
import { createFirstTxRoot } from '@firsttx/prepaint';
createFirstTxRoot(document.getElementById('root')!, <App />);
How does it work?
createFirstTxRoot
:
- Saves the screen to IndexedDB when leaving the page
- Restores instantly before React loads on revisit
- Mounts the actual app via Hydration or Client Render
// 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(),
}),
),
}),
// ttl is optional - defaults to 5 minutes
ttl: 5 * 60 * 1000,
});
import { useSyncedModel } from '@firsttx/local-first';
import { CartModel } from './models/cart';
function CartPage() {
const { data: cart } = useSyncedModel(CartModel, () => fetch('/api/cart').then((r) => r.json()));
if (!cart) return <Skeleton />;
return (
<div>
{cart.items.map((item) => (
<CartItem key={item.id} {...item} />
))}
</div>
);
}
import { startTransaction } from '@firsttx/tx';
async function addToCart(item) {
const tx = startTransaction();
await tx.run(
() =>
CartModel.patch((draft) => {
draft.items.push(item);
}),
{
compensate: () =>
CartModel.patch((draft) => {
draft.items.pop();
}),
},
);
await tx.run(() =>
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item),
}),
);
await tx.commit();
}
How does it work?
Transactions bundle multiple steps into one atomic operation. If the server request fails, the compensate
function automatically executes to revert local changes.
Experience FirstTx with working examples
Test each feature with 9 different scenarios
Open Playground
Playground Code
- Prepaint: Instant restore / Router integration
- Sync: Conflict resolution / Timing attacks / Staleness
- Tx: Concurrent updates / Rollback chains / Network chaos
💡 Curious about API options? → Jump to API Reference
Creates React entry point and sets up Prepaint capture.
import { createFirstTxRoot } from '@firsttx/prepaint';
createFirstTxRoot(document.getElementById('root')!, <App />, { transition: true });
Parameters
container: HTMLElement
- DOM element to mount toelement: ReactElement
- React element to renderoptions?: { transition?: boolean }
- Whether to use ViewTransition (default:true
)
Defines an IndexedDB model.
import { defineModel } from '@firsttx/local-first';
import { z } from 'zod';
const CartModel = defineModel('cart', {
schema: z.object({ items: z.array(...) }),
ttl: 5 * 60 * 1000, // optional
});
Parameters
key: string
- IndexedDB key (must be unique)options.schema: ZodSchema
- Zod schemaoptions.ttl?: number
- Time-to-live in milliseconds (default:5 * 60 * 1000
= 5 minutes)options.version?: number
- Schema version for migrationsoptions.initialData?: T
- Initial data (required if version is set)options.merge?: (current: T, incoming: T) => T
- Conflict resolution function
Automatically syncs model with server.
const { data, patch, sync, isSyncing, error, history } = useSyncedModel(CartModel, fetchCart, {
syncOnMount: 'stale',
onSuccess: (data) => console.log('Synced'),
onError: (err) => console.error(err),
});
Parameters
model: Model<T>
- Model created with defineModelfetcher: () => Promise<T>
- Function to fetch server dataoptions?: SyncOptions
SyncOptions
syncOnMount?: 'always' | 'stale' | 'never'
(default:'stale'
)'always'
: Always sync on mount'stale'
: Only sync when TTL exceeded'never'
: Manual sync only
onSuccess?: (data: T) => void
onError?: (error: Error) => void
Returns
data: T | null
- Current datapatch: (fn: (draft: T) => void) => Promise<void>
- Local updatesync: () => Promise<void>
- Manual syncisSyncing: boolean
- Whether syncingerror: Error | null
- Sync errorhistory: ModelHistory
- Metadataage: number
- Time elapsed since last update (ms)isStale: boolean
- Whether TTL exceededupdatedAt: number
- Last update timestamp
Automatically synchronizes model changes across all open tabs using BroadcastChannel API.
- ~1ms sync latency between tabs
- Zero network overhead (browser-internal)
- Automatic consistency across all tabs
- Graceful degradation for older browsers (97%+ support)
Starts an atomic transaction.
import { startTransaction } from '@firsttx/tx';
const tx = startTransaction({ transition: true });
await tx.run(
() =>
CartModel.patch((draft) => {
/* update */
}),
{
compensate: () =>
CartModel.patch((draft) => {
/* rollback */
}),
retry: {
maxAttempts: 3,
delayMs: 1000,
backoff: 'exponential', // or 'linear'
},
},
);
await tx.commit();
tx.run Parameters
fn: () => Promise<T>
- Function to executeoptions?.compensate: () => Promise<void>
- Rollback function on failureoptions?.retry: RetryConfig
- Retry configurationmaxAttempts?: number
- Maximum retry attempts (default:1
)delayMs?: number
- Base delay between retries in milliseconds (default:100
)backoff?: 'exponential' | 'linear'
- Backoff strategy (default:'exponential'
)
Backoff strategies:
exponential
: 100ms → 200ms → 400ms → 800ms (delay × 2^attempt)linear
: 100ms → 200ms → 300ms → 400ms (delay × attempt)
React hook for simplified transaction management.
import { useTx } from '@firsttx/tx';
const { mutate, isPending, isError, error } = useTx({
optimistic: async (item) => {
await CartModel.patch((draft) => draft.items.push(item));
},
rollback: async (item) => {
await CartModel.patch((draft) => draft.items.pop());
},
request: async (item) =>
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item),
}),
onSuccess: () => toast.success('Done!'),
});
// Usage
<button onClick={() => mutate(newItem)} disabled={isPending}>
{isPending ? 'Adding...' : 'Add to Cart'}
</button>;
Parameters
config.optimistic
- Local state update functionconfig.rollback
- Rollback functionconfig.request
- Server request functionconfig.transition?
- Use ViewTransition (default:true
)config.retry?
- Retry config{ maxAttempts?, delayMs?, backoff?: 'exponential' | 'linear' }
config.onSuccess?
- Success callbackconfig.onError?
- Error callback
Returns
mutate(variables)
- Execute transactionisPending
,isError
,isSuccess
- State flagserror
- Error object
💡 Need practical examples? → Return to Examples
Automatically saves screen to IndexedDB when leaving page, and instantly restores before React loads on revisit.
Core Technology
- Inline boot script (<2KB)
- ViewTransition integration
- Automatic hydration fallback
Performance
- Blank Screen Time: ~0ms
- Prepaint Time: <20ms
- Hydration Success: >80%
Connects IndexedDB and React via useSyncExternalStore to guarantee synchronous state reads.
Core Features
- TTL-based auto expiration
- Stale detection and auto refetch
- Zod schema validation
- Version management
DX Improvements
- ~90% reduction in sync boilerplate
- React Sync Latency: <50ms
Bundles optimistic updates and server requests into one transaction, with automatic rollback on failure.
Core Features
- Compensation-based rollback (reverse execution)
- Retry strategies (linear/exponential backoff)
- ViewTransition integration
Reliability
- Rollback Time: <100ms
- ViewTransition Smooth: >90%
Browser | Min Version | ViewTransition | Status |
---|---|---|---|
Chrome/Edge | 111+ | ✅ Full support | ✅ Tested |
Firefox | Latest | ❌ Not supported | ✅ Graceful degradation |
Safari | 16+ | ❌ Not supported | ✅ Graceful degradation |
Core features work everywhere even without ViewTransition.
✅ Choose FirstTx
- Internal tools (CRM, dashboards, admin panels)
- Apps with frequent revisits (10+ times/day)
- Apps without SEO requirements
- Complex client-side interactions
❌ Consider alternatives
- Public landing/marketing sites → SSR/SSG
- First-visit performance is critical → SSR
- Always need latest data → Server-driven UI
Q: UI duplicates on refresh
A: Enable overlay: true
option in Vite plugin.
Q: Hydration warnings occur
A: Add data-firsttx-volatile
attribute to frequently changing elements.
Q: TypeScript errors
A: Add global declaration: declare const __FIRSTTX_DEV__: boolean
Find more solutions at GitHub Issues.
MIT © joseph0926