Getting Started
This guide uses a Vite + React 19 CSR app as the running example and walks through,
- Level 1 - Prepaint only: experience “no blank screen on revisit”
- Level 2 - Local-First: add offline-durable 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 19 (or planning to migrate to it)
- 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.
// 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(), // ✅ injects the Prepaint boot script
],
});
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 hydrate / render the real app.
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 via hydration or client render.
- Open any page in your app and scroll down a bit.
- Hit refresh or go back/forward a few times.
If you see the last screen immediately, without a white flash, Prepaint is working.
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 ensures that,
- The user immediately sees the item added to the UI.
- If the server request fails,
compensateruns and the UI returns 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,
- No blank screen on revisit - the last screen is restored instantly (Prepaint)
- IndexedDB-backed local data with TTL/multi-tab sync (Local-First)
- Transactional optimistic updates with retry/rollback/timeout (Tx)
To dive deeper into each layer,
cover the internal design and advanced options.