logo
Firsttx

빠른 시작

이 가이드는 Vite + React 19 기반 CSR 앱을 예시로,

  1. **Prepaint만 먼저 붙여서 “재방문 빈 화면 제거”**를 경험하고,
  2. 필요하다면 Local-First로 데이터 내구성을 추가하고,
  3. 마지막으로 Tx로 낙관적 UI를 트랜잭션으로 감싸는 순서를 다룹니다.
어디까지 따라가야 할까요?
  • Prepaint만 써보고 싶다면 1~3단계까지만 진행해도 충분합니다.

  • 로컬 데이터 내구성까지 필요하다면 4~5단계를 함께 보세요.

  • 낙관적 트랜잭션까지 체험해 보고 싶다면 6단계까지 진행하면 됩니다.


0. 사전 준비

  • React 19 (또는 React 19로 마이그레이션 예정)
  • Vite 기반 CSR 앱 (Next.js 16 / App Router에서의 사용법은 별도 문서에서 다룹니다)
  • 타입스크립트 사용 가정

1. 패키지 설치

우선 세 레이어를 모두 설치해둡니다.
(당장은 Prepaint만 쓸 예정이지만, 나중에 확장하기 쉬우면서 설치 과정도 한 번에 끝냅니다.)

패키지 설치
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx zod
Tx 단독 사용에 대해

@firsttx/tx는 런타임 외부 의존성이 없어, 단독으로도 사용할 수 있습니다. 다만 이 가이드에서는 Local-First 모델을 낙관적 업데이트 대상으로 사용하는 예제를 중심으로 설명합니다.


2. Vite 플러그인 설정 (Prepaint 부트 스크립트)

Prepaint는 Vite 플러그인으로 부트 스크립트를 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(), // ✅ Prepaint 부트 스크립트 자동 주입
  ],
});

이 플러그인은 빌드 시 HTML에 작은 부트 스크립트를 주입합니다. 이 스크립트는,

  1. 페이지 진입 시 IndexedDB에서 마지막 DOM 스냅샷을 읽어오고
  2. 유효한 스냅샷이 있다면 React보다 먼저 복원한 뒤
  3. 그 다음에 React가 하이드레이션/렌더링을 진행하도록 도와줍니다.

3. 엔트리 포인트 교체 (createFirstTxRoot) – ⭐ 필수

이제 기존 ReactDOM.createRoot 대신 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는 다음을 처리합니다.

  1. 페이지를 떠날 때 현재 화면을 IndexedDB에 캡처
  2. 재방문 시 React가 로드되기 전에 스냅샷을 즉시 복원
  3. ViewTransition API가 가능하면 복원을 크로스페이드로 감싸고
  4. 마지막으로 React 앱을 하이드레이션 또는 클라이언트 렌더로 마운트
여기까지 확인해 보세요
  1. 앱에서 아무 페이지나 연 뒤, 스크롤을 살짝 내려 둡니다.
  2. 새로고침 또는 뒤로가기를 여러 번 반복해 봅니다.
  3. 흰 화면 없이 바로 마지막 화면이 보였다면 Prepaint가 제대로 동작하고 있는 것입니다.

지금 상태에서 이미 Prepaint만 “도입 완료” 상태입니다. 다음 단계부터는 Local-First / Tx로 기능을 확장합니다.


4. Local-First 모델 정의 (defineModel)

이제 IndexedDB에 저장될 모델을 정의해봅니다. 예시로 CartModel을 하나 만들어보겠습니다.

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은 선택사항입니다. 기본값은 5분(5 * 60 * 1000 ms)입니다.
  ttl: 5 * 60 * 1000,
  // 초기값을 지정하면 첫 방문 시에도 null 대신 이 값으로 시작합니다.
  initialData: {
    items: [],
  },
});
  • schema: Zod 스키마로 모델 구조를 정의합니다.

    • Local-First는 이 스키마를 기준으로 저장 전후에 데이터를 검증합니다.
    • 스키마에 맞지 않는 데이터는 IndexedDB에서 제거됩니다.
  • ttl: 데이터가 “stale”로 간주되기까지의 시간 (ms). 기본값은 5분입니다.

  • initialData: 디스크에 아무 값도 없을 때 사용할 초기 값입니다.


5. 컴포넌트에서 동기화 훅 사용 (useSyncedModel)

useSyncedModel은 모델과 서버를 자동으로 동기화해 주는 React 훅입니다.

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, {
    // 기본값은 "always" 입니다.
    // "stale"로 설정하면 TTL이 지난 경우에만 자동 동기화를 수행합니다.
    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>
  );
}

이렇게 하면,

  • 최초 진입 시

    • IndexedDB에 데이터가 있으면 그걸 먼저 동기적으로 읽어와 즉시 렌더합니다.
    • TTL이 지나서 stale 상태라면, 백그라운드에서 서버 동기화를 진행합니다.
  • 재방문 시

    • 네트워크 상태와 상관없이 “마지막으로 저장된 장바구니”를 바로 보여줄 수 있습니다.
여기까지 확인해 보세요
  1. 페이지에 들어가 장바구니를 채운 뒤, 새로고침을 여러 번 해 봅니다.
  2. 네트워크 탭을 잠깐 Offline으로 전환한 뒤 다시 접속해 보세요.
  3. 마지막 장바구니 상태가 그대로 보인다면 Local-First가 잘 동작하는 것입니다.


6. Tx로 낙관적 업데이트를 트랜잭션으로 감싸기 (선택)

이제 장바구니에 아이템을 추가하는 예시로 Tx를 사용해 봅니다.

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 });

  // 1단계: 로컬 모델 낙관적 업데이트
  await tx.run(
    () =>
      CartModel.patch((draft) => {
        draft.items.push(item);
      }),
    {
      // 실패 시 보상(rollback)
      compensate: () =>
        CartModel.patch((draft) => {
          draft.items.pop();
        }),
    },
  );

  // 2단계: 서버에 실제 반영
  await tx.run(() =>
    fetch("/api/cart", {
      method: "POST",
      body: JSON.stringify(item),
    }),
  );

  // 모든 단계가 성공하면 커밋
  await tx.commit();
}

이 코드는 다음을 보장합니다.

  1. 사용자는 즉시 UI에서 아이템이 추가된 것을 보고,
  2. 서버 요청이 실패하면 compensate가 실행되어 UI가 원래 상태로 되돌아가며,
  3. 전체 과정을 DevTools에서 하나의 트랜잭션 타임라인으로 추적할 수 있습니다.
  4. transition: true일 때 ViewTransition을 지원하는 브라우저라면, 롤백 경로도 자연스러운 전환 애니메이션으로 감쌉니다.

7. DevTools와 다음 단계

FirstTx는 Prepaint / Local-First / Tx의 모든 중요한 이벤트를 DevTools로 전송합니다.

  • Chrome 확장 프로그램 FirstTx DevTools를 설치하고,
  • DevTools → “FirstTx” 패널을 연 뒤,
    • prepaint, model, tx 카테고리를 필터링해 보세요.

그러면,

  • Prepaint가 언제 스냅샷을 캡처/복원했는지,
  • Local-First가 언제 데이터를 동기화/검증/정리했는지,
  • Tx가 어떤 순서로 단계를 실행/재시도/롤백했는지

타임라인으로 한눈에 확인할 수 있습니다.


정리: 지금까지 구현한 것

이 가이드의 1~6단계까지 따라왔다면, 현재 앱은 이미 다음을 제공합니다.

  1. 재방문 시 빈 화면 없이 마지막 화면 복원 (Prepaint)
  2. IndexedDB 기반 로컬 데이터 레이어 + TTL/멀티탭 동기화 (Local-First)
  3. 트랜잭션 기반 낙관적 업데이트 + 재시도/롤백/타임아웃 (Tx)

각 레이어의 동작 원리/세부 옵션이 궁금하다면,

문서에서 이어서 살펴보시면 됩니다.