한국어 버전은 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/txFor specific features only
Revisit optimization only
pnpm add @firsttx/prepaintRevisit + Data synchronization
pnpm add @firsttx/prepaint @firsttx/local-firstData 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
Debug FirstTx apps with full visibility into event lifecycle.
Chrome Web Store - Firsttx Devtools
Timeline View
- Visual timeline showing Prepaint → Model → Tx execution
- Event grouping by transaction ID and model name
- Status indicators (success/error/pending)
Event Filtering
- Filter by category, priority, or search text
- Error-only mode for quick debugging
- Real-time event count display
Common Use Cases
// Debug: "Why didn't prepaint restore?"
// → Check 'restore' event in DevTools
// → Look for 'hydration.error' events
// Debug: "Which model keeps re-syncing?"
// → Filter by Model category
// → Check 'sync.start' event trigger field
// Debug: "Transaction rolled back but UI broken"
// → Find your txId in Timeline
// → Check if 'rollback.fail' event exists- Chrome 111+ (Edge 111+)
- FirstTx packages with DevTools support:
@firsttx/prepaint@^0.3.3@firsttx/local-first@^0.4.1@firsttx/tx@^0.2.2
Learn more: DevTools Documentation
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) => voidonError?: (error: Error) => void
Returns
data: T | null- Current datapatch: (fn: (draft: T) => void) => Promise<void>- Update existing data via draft mutationreplace: (data: T) => Promise<void>- Replace entire datasync: () => 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
React 19+ only. Suspense-enabled hook for declarative data fetching.
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 with defineModelfetcher: (current: T | null) => Promise<T>- Async data fetcher
Returns T (never null)
Key differences:
- ✅ No manual loading checks - automatic Suspense integration
- ✅ Non-nullable return type for better type safety
- ✅ Automatic Error Boundary integration
- ❌ React 19+ required (uses
use()hook) - ❌ Must wrap in
<Suspense>boundary - ❌ Read-only (use
useSyncedModelfor mutations)
Use patch() when:
- You have existing data (
data !== null) - You want to modify specific fields via Immer-style draft mutation
- Example: Adding items to a cart, updating counters
// Modify existing data
await patch((draft) => {
draft.items.push(newItem);
draft.total += newItem.price;
// No return statement - mutate draft in place
});Use replace() when:
- You want to completely replace data (including
null) - Initial state is
nulland you're setting first value - Example: Login (null → user data), Logout (user → null)
// Login - set initial data
await replace({ accessToken: 'xxx', user: {...} });
// Logout - clear data
await replace(null);patch() requires existing data
If you try to use patch() when data is null, you'll get an error unless you've provided initialData:
// ❌ Error: Cannot patch when data is null
const AuthModel = defineModel('auth', {
schema: AuthSchema.nullable(),
initialData: null, // data starts as null
});
await AuthModel.patch((draft) => {
draft.token = 'xxx'; // Error: Cannot read property of null
});
// ✅ Use replace instead
await AuthModel.replace({ token: 'xxx', user: {...} });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





