logo
Firsttx

Getting Started

This guide uses a Vite + React 19 CSR app as the running example and walks through,

  1. Level 1 - Prepaint only: experience “no blank screen on revisit”
  2. Level 2 - Local-First: add offline-durable data
  3. Level 3 - Tx: wrap optimistic UI in transactions
How far should you go?
  • 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.)

Install packages
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx zod
About using Tx alone

@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.

ts
// 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,

  1. Reads the last DOM snapshot from IndexedDB on page load.
  2. If a valid snapshot is found, restores it before React.
  3. Then lets React hydrate / render the real app.

3. Replace the entry point (createFirstTxRoot) - ⭐ Level 1

Now replace ReactDOM.createRoot with createFirstTxRoot.

tsx
// 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,

  1. Captures the current screen into IndexedDB before the user leaves the page.
  2. On revisit, restores the snapshot before React loads.
  3. Wraps the restore in a ViewTransition cross-fade when supported.
  4. Finally mounts the React app via hydration or client render.
Quick check: is Prepaint working?
  1. Open any page in your app and scroll down a bit.
  2. Hit refresh or go back/forward a few times.
  3. 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.

ts
// 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.

tsx
// 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.
Quick check: is Local-First working?
  1. Go to the page, add a few items to the cart, then refresh the page a few times.
  2. Temporarily set your network tab to Offline and reload the page.
  3. 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.

ts
// 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,

  1. The user immediately sees the item added to the UI.
  2. If the server request fails, compensate runs and the UI returns to its previous state.
  3. The whole flow appears as a single transaction timeline in DevTools.
  4. With transition: true and 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, tx categories.

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,

  1. No blank screen on revisit - the last screen is restored instantly (Prepaint)
  2. IndexedDB-backed local data with TTL/multi-tab sync (Local-First)
  3. Transactional optimistic updates with retry/rollback/timeout (Tx)

To dive deeper into each layer,

cover the internal design and advanced options.