Getting Started
This guide uses a Vite + React CSR app as the running example and walks through,
- Level 1 - Prepaint only: replay the last visual snapshot during revisit boot
- Level 2 - Local-First: add persisted client data
- Level 3 - Tx: wrap optimistic UI in transactions
Only want to try Prepaint? Steps 1-3 are enough.
Need durable local data as well? Continue through steps 4-5.
Want to try transactional optimistic UI? Go all the way to step 6.
0. Prerequisites
- React 18.2+
- A Vite-based CSR app
(Using Next.js 16 / App Router is also possible, but is covered in a separate guide) - TypeScript is assumed
1. Install packages
We’ll install all three layers up front.
(We’ll start with Prepaint only, but this makes it easy to extend later.)
@firsttx/tx has no runtime dependency on the other FirstTx packages and can be used on its own. This guide, however, focuses on examples where Tx operates on Local-First models for optimistic updates.
2. Vite plugin (Prepaint boot script)
Prepaint ships as a Vite plugin that injects a boot script into your HTML. Currently, the Prepaint plugin is provided for Vite only.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { firstTx } from "@firsttx/prepaint/plugin/vite";
export default defineConfig({
plugins: [
react(),
firstTx(),
],
});
At build time, this plugin injects a small boot script into your HTML. The script,
- Reads the last DOM snapshot from IndexedDB on page load.
- If a valid snapshot is found, restores it before React.
- Then lets React mount the real app.
Snapshots always render in a non-interactive overlay outside the React root. React mounts into an empty root, and the overlay is removed after the first commit.
3. Replace the entry point (createFirstTxRoot) - ⭐ Level 1
Now replace ReactDOM.createRoot with createFirstTxRoot.
// main.tsx
import React from "react";
import { createFirstTxRoot } from "@firsttx/prepaint";
import { App } from "./App";
createFirstTxRoot(
document.getElementById("root")!,
<React.StrictMode>
<App />
</React.StrictMode>,
);
createFirstTxRoot does the following,
- Captures the current screen into IndexedDB before the user leaves the page.
- On revisit, restores the snapshot before React loads.
- Wraps the restore in a ViewTransition cross-fade when supported.
- Finally mounts the React app and removes the temporary visual cache during handoff.
- Open any page in your app and scroll down a bit.
- Hit refresh or go back/forward a few times.
- If the last visual snapshot appears before the React app mounts, Prepaint is working. Record the timing under your target device and network profile.
At this point, Prepaint is effectively “installed”. The next steps add Local-First and Tx on top.
4. Define a Local-First model (defineModel)
Now let’s define a model that will be stored in IndexedDB.
We’ll use a simple CartModel as an example.
// 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. Default is 5 minutes (5 * 60 * 1000 ms).
ttl: 5 * 60 * 1000,
// When no value exists on disk, this is used as the initial state.
initialData: {
items: [],
},
});
-
schema- Zod schema describing the model structure.- Local-First validates data before/after storing it based on this schema.
- If data doesn’t match, the IndexedDB entry is removed.
-
ttl- time in ms before the data is considered “stale”. Default is 5 minutes. -
initialData- initial value when there is nothing on disk.
5. Use the sync hook in a component (useSyncedModel)
useSyncedModel is a React hook that syncs a model with the server.
// CartPage.tsx
import { useSyncedModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
async function fetchCart(current: unknown) {
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,
} = useSyncedModel(CartModel, fetchCart, {
// The default is "always".
// "stale" means it only auto-syncs when the TTL has expired.
syncOnMount: "stale",
});
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>
))}
{isSyncing && (
<p className="text-xs text-muted-foreground">
Syncing latest data…
</p>
)}
{error && (
<p className="text-xs text-destructive">
Failed to sync: {error.getUserMessage?.() ?? error.message}
</p>
)}
</div>
);
}
What this gives you,
-
On first visit
- If there is data in IndexedDB, it is read synchronously and rendered immediately.
- If the data is stale (TTL exceeded), a background server sync runs.
-
On revisit
- Regardless of network conditions, the user immediately sees the last stored cart.
- Go to the page, add a few items to the cart, then refresh the page a few times.
- Temporarily set your network tab to Offline and reload the page.
If you still see the last cart state, Local-First is working.
6. Wrap optimistic updates in Tx (optional)
Finally, let’s use Tx for transactional optimistic updates when adding an item to the cart.
// cart-actions.ts
import { startTransaction } from "@firsttx/tx";
import { CartModel } from "./models/cart";
type CartItem = {
id: string;
name: string;
qty: number;
};
export async function addToCart(item: CartItem) {
const tx = startTransaction({ transition: true });
// Step 1: optimistic local update
await tx.run(
() =>
CartModel.patch((draft) => {
draft.items.push(item);
}),
{
// Compensation in case later steps fail
compensate: () =>
CartModel.patch((draft) => {
draft.items.pop();
}),
},
);
// Step 2: persist to the server
await tx.run(() =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(item),
}),
);
// Commit once all steps have succeeded
await tx.commit();
}
This flow is designed so that,
- The user immediately sees the item added to the UI.
- If the server request fails,
compensateruns and attempts to return the UI to its previous state. - The whole flow appears as a single transaction timeline in DevTools.
- With
transition: trueand ViewTransition support, the rollback path is wrapped in a smooth visual transition.
7. DevTools and where to go next
FirstTx sends rich events to DevTools for all three layers,
- Prepaint / snapshot capture & restore
- Local-First / load, sync, validation/storage errors
- Tx / transaction start, step success/fail, retry, rollback, timeout
To see them,
- Install the FirstTx DevTools Chrome extension.
- Open DevTools → “FirstTx” panel.
- Filter by
prepaint,model,txcategories.
You’ll be able to see,
- When Prepaint captured/restored snapshots
- When Local-First synced/validated/cleaned data
- How Tx executed/retried/rolled back each transaction
Recap: what you have now
If you followed steps 1-6, your app now provides,
- Boot-time visual snapshot replay to reduce revisit blank time (Prepaint)
- IndexedDB-backed local data with TTL/multi-tab sync (Local-First)
- Saga-like optimistic steps with retry/compensation/timeout (Tx)
To dive deeper into each layer,
cover the internal design and advanced options.