Reactive UI with zero boilerplate.
Write JavaScript; let the compiler handle signals, derived values, and DOM updates.
Quick Start · Core Concepts · Examples · Docs · Playground
function Counter() {
let count = $state(0)
const doubled = count * 2 // auto-derived
return <button onClick={() => count++}>{doubled}</button>
}No useMemo. No dependency arrays. No .value. Just JavaScript.
"Write JavaScript; the compiler handles reactivity." No
.value, no deps arrays, no manual memo wiring. Not pitching "better React/Vue/Svelte" — Fict is a different mental model: compile-time reactivity on plain JS. The gain: less code, lower cognitive overhead.
| Pain Point | React | Vue 3 | Solid | Svelte 5 | Fict ✨ |
|---|---|---|---|---|---|
| State syntax | useState() + setter |
ref() + .value |
createSignal() + () |
$state() |
$state() |
| Derived values | useMemo + deps |
computed() |
createMemo() |
$derived() |
automatic 🔥 |
| Props destructure | ✅ | ❌ breaks reactivity | ✅ ($props()) |
✅ | |
| Control flow | native JS | v-if/v-for |
<Show>/<For> |
{#if}/{#each} |
native JS |
Fict's bet:
- React-style TSX ergonomics with destructuring-friendly props and native control flow.
- Solid-style fine-grained DOM updates without getter calls in component code.
- Compile-time guarantees that fail closed when the compiler cannot prove reactive behavior.
The goal is not to be the smallest possible runtime. Fict trades compiler complexity for React-like authoring, automatic derivation, package metadata, and strict reactivity guarantees.
npm install fict
npm install -D @fictjs/vite-plugin # Vite users📦 Counter App — full example
import { $state, render } from 'fict'
export function Counter() {
let count = $state(0)
const doubled = count * 2 // auto-derived
return (
<div class="counter">
<h1>Fict Counter</h1>
<div class="card">
<button onClick={() => count--}>-</button>
<span class="count">{count}</span>
<button onClick={() => count++}>+</button>
</div>
<p class="doubled">Doubled: {doubled}</p>
</div>
)
}
render(() => <Counter />, document.getElementById('app')!)⚙️ Vite config
// vite.config.ts
import { defineConfig } from 'vite'
import fict from '@fictjs/vite-plugin'
export default defineConfig({
plugins: [fict()],
})🔧 TypeScript config
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "fict"
}
}- 🎮 Counter
let count = $state(0)
count++ // ✅ direct mutation
count = count + 1 // ✅ assignmentlet price = $state(100)
let quantity = $state(2)
const subtotal = price * quantity // auto-derived
const tax = subtotal * 0.1 // auto-derived
const total = subtotal + tax // auto-derivedThe compiler builds a dependency graph and only recomputes what's needed.
Single-use derived values, including proven scalar JSX text bindings, may be inlined as an optimization.
Unused implicit scalar memos can be removed under the same value proof; use $memo to
force an explicit memo node. See the inlining and elimination rules.
$effect(() => {
console.log(`count is now ${count}`)
return () => {
/* cleanup */
}
})This is the most important concept to understand.
function Counter() {
console.log('A') // 🔵 Runs ONCE
let count = $state(0)
const doubled = count * 2
console.log('B', doubled) // 🟢 Runs on EVERY count change
return (
<button onClick={() => count++}>
{(console.log('C'), doubled)} {/* 🟢 Runs on every change */}
{(console.log('D'), 'static')} {/* 🔵 Runs ONCE */}
</button>
)
}| Phase | Output | Why |
|---|---|---|
| Initial render | A → B 0 → C → D |
Everything runs once |
| After click (count: 0→1) | B 2 → C |
A and D don't run! |
| Framework | What happens on state change |
|---|---|
| React | Entire component function re-runs |
| Solid | Component runs once; you manually wrap derived values |
| Fict | Component runs once; code depending on state auto-recomputes |
Fict splits your component into reactive regions:
- 🔵 Code before
$state: runs once - 🟢 Expressions using state (
count * 2): recompute when dependencies change - 🔵 Static JSX: runs once
Runnable examples live under examples/, including Vite, Webpack, SSR, streaming, resumability,
forms, dashboards, nested routing, and auth/error/loading flows. The docs index at
packages/docs-site/docs/examples/index.md lists the current set.
function App() {
let show = $state(true)
return (
<div>
{show && <Modal />}
{show ? <A /> : <B />}
</div>
)
}No <Show> or {#if} — just JavaScript.
function TodoList() {
let todos = $state([
{ id: 1, text: 'Learn Fict' },
{ id: 2, text: 'Build something' },
])
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}No <For> or v-for — just .map().
import { $async, $state, ErrorBoundary, Suspense } from 'fict'
async function fetchUserName(id: number, signal: AbortSignal) {
const response = await fetch(`/api/user/${id}`, { signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
const user = (await response.json()) as { name: string }
return user.name
}
function UserProfile() {
let userId = $state(1)
const name = $async(context => fetchUserName(userId, context.signal))
return (
<section>
<button onClick={() => userId++}>Next user</button>
<ErrorBoundary resetKeys={userId} fallback={error => <p>{String(error)}</p>}>
<Suspense fallback={<p>Loading…</p>}>
<p>{name}</p>
</Suspense>
</ErrorBoundary>
</section>
)
}$async exposes the resolved value of an owned async graph node. Inputs are
captured synchronously; replacement and disposal abort obsolete work. Readiness
propagates through derived values and bindings to Suspense and ErrorBoundary.
Use resource from fict/plus when you also need shared requests or cache policy.
Ordinary Promise-valued memos keep their existing behavior, and native await
does not preserve reactive tracking. See the async declaration guide
and the runnable async data example.
function Greeting({ name, age = 18 }: { name: string; age?: number }) {
const label = `${name} (${age})` // auto-derived from props
return <span>{label}</span>
}Destructuring works. No toRefs() or special handling needed.
// ✍️ Your code
function Counter() {
let count = $state(0)
const doubled = count * 2
return <div>{doubled}</div>
}
// ⚡ Compiled output (simplified)
function Counter() {
const count = createSignal(0)
const doubled = createMemo(() => count() * 2)
const div = document.createElement('div')
createEffect(() => {
div.textContent = doubled()
})
return div
}You write the simple version. The compiler generates the efficient version.
import { ErrorBoundary } from 'fict'
;<ErrorBoundary fallback={err => <p>Error: {String(err)}</p>}>
<RiskyComponent />
</ErrorBoundary>import { Suspense } from 'fict'
import { reactive } from 'fict/advanced'
import { resource, lazy } from 'fict/plus'
const userResource = resource({
suspense: true,
fetch: (_, id: number) => fetch(`/api/user/${id}`).then(r => r.json()),
})
const LazyChart = lazy(() => import('./Chart'))
function Profile(props: { id: number }) {
const user = userResource.read(reactive(() => props.id))
return (
<Suspense fallback="Loading...">
<h1>{user.data?.name}</h1>
<LazyChart />
</Suspense>
)
}Fict SSR supports shell-first streaming with Suspense boundary patching:
import { renderToPipeableStream } from '@fictjs/ssr'
const { pipe, shellReady, allReady } = renderToPipeableStream(() => <App />, {
mode: 'shell',
})
pipe(res)
await shellReady
await allReady🧪 Partial prerendering (Preview)
import { renderToPartial } from '@fictjs/ssr/experimental'
const { shell, stream } = renderToPartial(() => <App />, { mode: 'shell' })
// shell: complete fallback HTML
// stream: deferred boundary patchesrenderToPartial is an advanced API (Preview in v1.0).
Resumable handlers and partial prerendering are active preview work, not a
stable Qwik-compatible contract.
import { $store, untrack } from 'fict'
import { resource, lazy } from 'fict/plus'
// Deep reactivity with path-level tracking
const user = $store({ name: 'Alice', address: { city: 'London' } })
user.address.city = 'Paris' // fine-grained update
// Derived values are auto-memoized, just like $state
const greeting = `Hello, ${user.name}` // auto-derived
// Method chains are also auto-memoized
const store = $store({ items: [1, 2, 3, 4, 5] })
const doubled = store.items.filter(n => n > 2).map(n => n * 2) // auto-memoized
// Dynamic property access works with runtime tracking
const value = store[props.key] // reactive, updates when key or store changes
// Escape hatch for black-box functions
const result = untrack(() => externalLib.compute(count))📊 $store vs $state
| Feature | $state |
$store |
|---|---|---|
| Depth | Shallow | Deep (nested objects) |
| Access | Direct value | Proxy-based |
| Mutations | Reassignment | Direct property mutation |
| Derived values | Auto-memoized | Auto-memoized |
| Best for | Primitives, simple objects | Complex nested state |
$store is the only user-facing deep store API. Internal createStore helpers
exist for compiler/runtime infrastructure and should not be imported by app code.
Fict components execute once on mount. Reactive updates happen through bindings/memos.
JSX-only reads → fine-grained DOM updates:
let count = $state(0)
return <div>{count}</div> // Only the text node updatesControl flow returns → compiler emits reactive branch bindings:
let count = $state(0)
if (count > 10) return <Special /> // branch swaps reactively when count changes
return <Normal />The compiler detects supported patterns (if-return, switch-return, try blocks containing
return branches) and lowers them to reactive conditionals.
| Feature | React+Compiler | Solid | Svelte 5 | Vue 3 | Fict ✨ |
|---|---|---|---|---|---|
| State syntax | useState() |
createSignal() |
$state() |
ref() |
$state() |
| Read state | count |
count() |
count |
count.value |
count |
| Update state | setCount(n) |
setCount(n) |
count = n |
count.value = n |
count = n |
| Derived values | auto | createMemo() |
$derived() |
computed() |
auto |
| Props destructure | ✅ | ❌ | via $props() |
via toRefs() |
✅ |
| Control flow | native JS | <Show>/<For> |
{#if}/{#each} |
v-if/v-for |
native JS |
| File format | .jsx/.tsx |
.jsx/.tsx |
.svelte |
.vue |
.jsx/.tsx |
| Rendering | VDOM | fine-grained | fine-grained | fine-grained | fine-grained |
Current strict workspace 0.35.0 compiler and runtime, including the async graph. Mean total durations are milliseconds, including browser rendering; lower is better. Chrome 152.0.7977.83 on macOS arm64, headless, with identical per-case throttling. Two complete rounds provide 30 samples per case; selection has 50.
The latest strict comparison uses two complete five-framework rounds, with all 1,450 CPU samples retained. CPU case values are milliseconds; geometric scores are normalized per case to the fastest of these five entries (lower is better). Method, creation costs and memory · Raw samples and build provenance.
| Benchmark | Vue Vapor | Solid | Svelte 5 | Fict | React Compiler |
|---|---|---|---|---|---|
| Create rows (1k) | 31.16 | 30.31 | 30.98 | 35.66 | 36.93 |
| Replace all rows (1k) | 35.30 | 35.24 | 36.34 | 41.74 | 45.12 |
| Partial update (every 10th row) | 19.67 | 19.01 | 20.09 | 20.51 | 25.32 |
| Select row | 5.71 | 6.68 | 9.45 | 6.48 | 13.77 |
| Swap rows | 22.23 | 22.86 | 22.80 | 23.33 | 146.93 |
| Remove row | 16.89 | 16.87 | 17.43 | 17.73 | 19.56 |
| Create many rows (10k) | 336.99 | 333.35 | 336.80 | 368.97 | 628.96 |
| Append rows (1k to 1k) | 37.18 | 36.99 | 37.06 | 41.66 | 43.63 |
| Clear rows (1k) | 15.61 | 18.80 | 17.30 | 17.50 | 27.55 |
| CPU geometric mean | 1.009 | 1.042 | 1.091 | 1.113 | 1.746 |
Fict's pooled score is 1.113341. Complete round scores are 1.116696 and 1.110706. The strict ≤1.10 check uses unrounded values: pooled FAIL; rounds FAIL / FAIL.
Versions: Vue Vapor 3.6.0-alpha.2 · Solid 1.9.3 · Svelte 5 5.42.1 · Fict 0.35.0 · React 19.0.0; babel-plugin-react-compiler 19.0.0-beta-37ed2a7-20241206.
Fict compiler/runtime revision: b8598af2; strict compilation with zero diagnostics.
Create 1k script time: 8.71 ms for Fict and 3.54 ms for Solid; paint time: 26.16 ms and 26.17 ms, respectively.
Round B reverses case and framework order. Both complete rounds and all 1,450 samples are retained. Their variation is descriptive, not a confidence interval; these local reference versions and workloads do not establish a universal ranking or isolate async overhead from the historical 0.34.0 measurements.
The fixture uses an explicit selector, per-row label signals, textContent,
and stable row captures. The compiler does not infer that representation for
arbitrary applications. The measured creation cases still spend more time in
JavaScript than the frozen Solid implementation. The earlier binding allocation
experiment and its mixed CPU results remain documented in the benchmark history.
All five frozen entries pass the official keyed checks; Fict also passes 15 model
checks through 11,000 rows. See the current results and remaining costs
and all samples, frozen artifacts and provenance.
Run node scripts/runtime-benchmark-report.mjs --check-readme to verify this table,
its versions and the unrounded target directly from the archived samples.
The benchmark history
retains the earlier 0.34.0 results and optimization experiments.
⚠️ Alpha — Fict is feature-complete for core compiler and runtime. API is stable, but edge cases may be refined. Don't use it in production yet.
- Compiler with HIR/SSA
- Stable
$state/$effectsemantics - Automatic derived value inference
- Explicit
$asyncdeclarations and owned async graph composition -
$storeinfict,resource/lazyinfict/plus -
startTransition,useTransition,useDeferredValueinfict - Vite plugin
- ESLint plugin
- Support sourcemap
- DevTools
- Router
- Testing library
- SSR / streaming
- Initial migration guide from React/Vue/Svelte/Solid
- Framework-specific migration recipes and maintained real-world templates
| Doc | Description |
|---|---|
| Architecture | How the compiler and runtime work |
| API Reference | Complete API documentation |
| Compiler Spec | Formal semantics |
| Reactive Graph Trace | Source plans and final helper calls |
| Reactivity Qualification | Implementation status and local validation evidence |
| Async Declarations | Owned resolved values and continuations |
| Async Graph Contract | Readiness, stale values and cleanup |
| Async SSR and Hydration | Request ownership and client handoff |
| Migration Guide | React/Vue/Svelte/Solid migration |
| Async Migration | Owned async nodes, Resource policy and continuation boundaries |
| Strict Guarantee Cookbook | Fail-closed diagnostic rewrites |
| Store API | $state vs $store ownership |
| Release Policy | SemVer and changelog standards |
| Scope Contract | Core/Satellite/Preview/Internal tiers |
| Preview Policy | Preview surface + degradation contract |
| ESLint Rules | Linting configuration |
| Diagnostic Codes | Compiler warnings reference |
| Config Profiles | Recommended dev/CI/prod settings |
| Compiler Maintenance | Compiler complexity guardrails |
| Cycle Protection | Dev-mode infinite loop detection |
| SSR SEO Guide | SEO best practices for SSR pages |
| SSR Performance | Snapshot size & render-mode tuning |
| SSR Deployment | Vercel/Cloudflare/edge deployment |
| Security Boundaries | HTML/snapshot/CSP/isolation review |
| DevTools | Vite plugin usage & auto-injection |
🔍 Linting & diagnostics
Install @fictjs/eslint-plugin and extend plugin:fict/recommended:
{
"plugins": ["fict"],
"extends": ["plugin:fict/recommended"]
}Key rules: nested component definitions (FICT-C003), missing list keys (FICT-J002), memo side effects (FICT-M003), empty $effect (FICT-E001), component return checks (FICT-C004), plus $state placement/alias footguns.
- Recommended config mirrors compiler warnings so IDE diagnostics stay aligned with build output.
- For strict CI gates, enable compiler
strictReactivity: trueto escalate theFICT-R006control-flow fallback diagnostic to a build error. strictGuaranteeis enabled by default for fail-closed guarantees.- Production compilation (
NODE_ENV=production) force-enablesstrictGuaranteeeven when an integration opts out. - Use
strictGuarantee: falseonly for non-production migration inventory. The maintained benchmark requires strict compilation. - CI can force strict mode with
FICT_STRICT_GUARANTEE=1during build steps. - Guarantee boundary reference:
docs/reactivity-guarantee-matrix.md. pnpm test:strict-applicationsruns the maintained production build/browser corpus. See application coverage, boundary counts and migration evidence.
Is Fict production-ready?
Alpha. Core is stable, but expect edge cases. Test thoroughly for critical apps.
Does Fict use a virtual DOM?
No. Fict compiles to direct DOM operations for surgical, fine-grained updates.
How does Fict handle arrays?
Default: immutable style (todos = [...todos, newTodo]). For deep mutations,
use spread to create new immutable data, or use Immer/Mutative, or use $store
from fict.
Can I use existing React components?
Not directly. Fict compiles to DOM operations, not React elements.
How big is the runtime?
Bundle size depends on the imported entrypoints and tree shaking. The runtime includes scheduling, hydration/resume, stores, and diagnostics. For measured table-workload timing and page memory, see the performance snapshot. Creation and memory costs remain higher than the local Solid reference.
The compiler has some limitations when handling conditional rendering patterns.
- Multiple sequential
if-returnbranches are compiled into reactive conditionals. ifblocks withoutreturnare auto-wrapped so reactive side effects still update.- Nested branch logic (e.g. inner
if/switch) and reactive prelude reads beforereturnare automatically kept reactive. When fine-grained lowering is not possible, the compiler enables a safe runtime fallback that tracks branch reads and re-runs the active branch.
Fict is built upon the brilliant ideas and relentless innovation of the open-source community. We express our deepest respect and gratitude to these projects:
- React — For defining the modern era of UI development. Its component model and declarative philosophy set the standard for developer experience.
- React Compiler — For proving that automatic memoization and compiler-owned reactivity can reduce manual dependency bookkeeping while preserving React semantics.
- Solid — For pioneering fine-grained reactivity and demonstrating the power of compilation. Its architecture is the bedrock upon which Fict's performance is built.
- Qwik — For its outstanding resumability-first SSR vision. Its approach to instant interactivity has been a major inspiration for Fict's resumable SSR direction.
- Million.js — For exploring compiler-assisted React performance and helping popularize the idea that UI performance can be shifted from runtime work into build-time analysis.
- alien-signals — For pushing the boundaries of signal performance. Its implementation provided critical guidance for Fict's reactive system.
MIT License · © Fict Contributors