Fig is a small TypeScript UI runtime for building apps and metaframeworks.
It's inspired by React (components, Fiber, hydration, state, etc.), but changes some things to better match platform semantics, such as AbortSignal and native prop names.
It also adds simple and intuitive primitives for data (streaming and revalidation), payload components (ordinary components rendered to a stream—you refresh them through the same data APIs), and assets (CSS, fonts, images, and other dependencies declared by the components that need them). You can use these primitives directly or through a framework—the Fig integration for TanStack Start already works!
Fig would not exist without React. I have enormous respect for the React team and their work; Fig deliberately builds on their ideas, often keeps their syntax, and explores what a clean-slate implementation can do differently. Fig also takes inspiration from Remix 3.
Fig is a working alpha. The DOM renderer, streaming SSR, hydration, data resources, payload protocol, and custom-renderer API are implemented and tested. The ecosystem is still small, APIs may change before 1.0, and Fig is not a drop-in replacement for React libraries.
Fig includes keyed data resources for loading, deduplication, Suspense, invalidation, refresh, cancellation, and server-to-client hydration. The API is a small renderer contract that richer data libraries can build on top of.
Server components are often delivered as a second application model with their own cache and refresh path. Fig's format is called payload, and a payload tree is just another data-resource value.
With TanStack Start, a payload component is one declaration:
// profile.payload.tsx
import { createPayloadComponent } from "@bgub/fig-dom";
import { Isomorphic, serverPayload } from "@bgub/fig-tanstack-start/payload";
import { FollowButton } from "./follow-button.tsx";
export const ProfilePage = createPayloadComponent<{ id: string }>({
key: ["profile-page"],
load: serverPayload(({ id }) => (
<article>
<h1>Profile {id}</h1>
<Isomorphic component={FollowButton} profileId={id} />
</article>
)),
});A route loads and renders it like any other data resource:
import { ensureRouteData } from "@bgub/fig-tanstack-router";
import { createFileRoute } from "@tanstack/solid-router";
import { ProfilePage } from "../profile.payload.tsx";
export const Route = createFileRoute("/profiles/$id")({
loader: ({ context, params }) =>
ensureRouteData(context, ProfilePage, { id: params.id }),
component: ProfileRoute,
});
function ProfileRoute() {
const { id } = Route.useParams();
return <ProfilePage id={id} />;
}The function passed to serverPayload runs on the server and stays out of the browser bundle. Isomorphic marks the part that should also render and hydrate on the client; FollowButton itself is still an ordinary component. Refreshing the tree uses the ordinary data-resource API.
import { refreshData, transition } from "@bgub/fig";
transition(() => refreshData(ProfilePage, { id: "42" }));The previous tree stays visible while the server renders and streams its replacement.
Using payload without TanStack Start
The framework adapter is built from the same public APIs. If you own the transport, render the tree into a response yourself:
// profile-endpoint.tsx
import { clientReference } from "@bgub/fig";
import { renderToPayloadStream } from "@bgub/fig-server/payload";
const FollowButton = clientReference<{ profileId: string }>({
id: "./follow-button.tsx#FollowButton",
});
function Profile({ id }: { id: string }) {
return (
<article>
<h1>Profile {id}</h1>
<FollowButton profileId={id} />
</article>
);
}
export function handleProfile(id: string): Response {
const payload = renderToPayloadStream(<Profile id={id} />);
return new Response(payload.stream, {
headers: { "content-type": payload.contentType },
});
}The browser side adapts that response into a Payload component:
import { createPayloadComponent } from "@bgub/fig-dom";
export const ProfilePage = createPayloadComponent<{ id: string }>({
key: ["profile-page"],
load: ({ id }, { signal }) => fetch(`/profiles/${id}`, { signal }),
resolveClientReference: ({ id }) => {
if (id === "./follow-button.tsx#FollowButton") {
return import("./follow-button.tsx").then(
(module) => module.FollowButton,
);
}
},
});<ProfilePage id={id} /> renders the decoded tree, and refreshData(ProfilePage, { id }) requests a new one. The component keeps the previous tree visible while the replacement streams in.
Effects, event handlers, DOM bindings, transitions, actions, and data loaders all have lifetimes. Fig represents each one with an AbortSignal:
useReactive(
(signal) => {
const socket = new WebSocket(`/rooms/${roomId}`);
signal.addEventListener("abort", () => socket.close(), { once: true });
},
[roomId],
);Effects return nothing. When their dependencies change or the component unmounts, their signal aborts. The same rule drives bind, events, transitions, actions, and data loaders, rather than giving each API a separate cleanup convention.
That familiarity is deliberate: Fig keeps React's core runtime model and diverges where a clean-slate implementation can make stronger choices.
Other differences include:
- Host behavior composes through render-time
mixdescriptors. - Native DOM events declared as
mix={on("click", handler)}, with native propagation and no exceptions. - Native host prop names such as
class,for, andstroke-width. - DOM access through
bind={(node, signal) => ...}instead of refs. - Explicit
readContext,readPromise, andreadDatainstead of one overloadeduse(resource). - Always-strict development rendering and diagnostics that throw before commit.
- TypeScript source and bundled types—no separate
@typespackage.
The full divergence list lives in docs/concepts/intentional-differences-from-react.md.
For a minimal interactive client surface, Fig is roughly half the size of React:
| Runtime | Minified | Minified + gzip |
|---|---|---|
| Fig | 92.5 kB | 29.3 kB |
| React 19.2.7 | 194.1 kB | 60.3 kB |
Measured with esbuild 0.28.1 in production mode. The Fig entry imports jsx, useState, createRoot, and on; the React entry imports jsx, useState, and createRoot from react and react-dom. Fig is 52% smaller minified and 51% smaller after gzip in this comparison.
Fig publishes ESM and its optional systems are tree-shakeable. For example, the complete @bgub/fig-dom export surface includes Payload decoding and the data store used by createPayloadComponent, but an application that does not import that API does not bundle those implementations. Repository size-limit checks for complete package entry points are therefore intentionally higher than a minimal application's named-import bundle.
- Small, minimal, and robust is best
- Use native platform semantics when possible
- Don't add React APIs unless they clearly strengthen Fig
pnpm add @bgub/fig @bgub/fig-domConfigure TypeScript to use the DOM renderer's JSX runtime:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@bgub/fig-dom",
"moduleResolution": "bundler"
}
}import { useState } from "@bgub/fig";
import { createRoot, on } from "@bgub/fig-dom";
function App() {
const [count, setCount] = useState(0);
return (
<button mix={on("click", () => setCount((count) => count + 1))}>
Count: {count}
</button>
);
}
const root = document.getElementById("root");
if (root === null) throw new Error("Missing root.");
createRoot(root).render(<App />);- Introduction and API overview
- Fiber architecture
- Rendering, commit, effects, and cleanup
- Suspense, streaming, and hydration
- Data resources
- Payload
- Asset resources
- Subsystem contracts and rationale
- @bgub/fig: core elements, host mixins, hooks, context, Suspense, data resources, error boundaries, and transitions.
- @bgub/fig-dom: browser rendering, hydration, delegated events,
bind, portals, native DOM props, and payload loading. - @bgub/fig-server: streaming server rendering, Suspense streaming, asset delivery, server errors, and payload rendering.
- @bgub/fig-reconciler: renderer internals for custom host configs, including the cooperative task scheduler.
- @bgub/fig-refresh: renderer-agnostic component-family tracking for hot refresh.
- @bgub/fig-vite: Vite plugins for Fast Refresh and server data-resource transforms.
- @bgub/fig-tanstack-router: TanStack Router code routes, Fig components and hooks, native links, and the private reactive-store bridge.
- @bgub/fig-tanstack-start: TanStack Start server/client rendering with one Fig-owned route-data store.
DevTools remains a private workspace preview while its public contract matures.
pnpm install
pnpm build
pnpm testDemo apps live in apps/.
pnpm devpnpm dev opens Turborepo's task TUI. Its task graph builds each prerequisite once, then starts the long-running package builders and demo servers as separate tasks. Vite provides browser HMR; the bundled server demos rebuild with tsdown and restart automatically.
The demo sites run through Portless:
https://fig-demo-client.localhosthttps://fig-demo-ssr.localhosthttps://fig-demo-payload.localhosthttps://fig-demo-tanstack-router.localhosthttps://fig-demo-tanstack-start.localhost
Use a demo package's dev:app script to run the underlying server without Portless.
Fig uses Tegami to release eight public packages as one alpha-versioned group. The five renderer/core packages publish to npm and JSR; the Vite package and two TanStack adapters publish to npm. Contributor tooling requires Node.js 24.
Add a changelog for a publishable change with pnpm tegami, or create an explicit .tegami/<description>.md file:
---
packages:
"@bgub/fig": minor
"@bgub/fig-dom": patch
---
## Describe the user-visible changeOn main, the publish workflow opens or updates a Version Packages pull request. Merging that pull request publishes the matching versions to their configured registries and creates one grouped GitHub release. See docs/releases.md for maintainer setup and recovery.
MIT