Skip to content

Latest commit

 

History

History
751 lines (573 loc) · 27.5 KB

File metadata and controls

751 lines (573 loc) · 27.5 KB

Fict Logo

Fict

Reactive UI with zero boilerplate.
Write JavaScript; let the compiler handle signals, derived values, and DOM updates.

CI npm version npm downloads license

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.


Why Fict?

"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 ❌ 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.


Quick Start

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"
  }
}

Online Examples


Core Concepts

$state — Reactive data

let count = $state(0)

count++ // ✅ direct mutation
count = count + 1 // ✅ assignment

Automatic derivations — No useMemo needed

let 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-derived

The 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 — Side effects

$effect(() => {
  console.log(`count is now ${count}`)
  return () => {
    /* cleanup */
  }
})

Execution Model: Not React, Not Solid

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!

The mental model

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

Examples

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.

Conditional rendering

function App() {
  let show = $state(true)

  return (
    <div>
      {show && <Modal />}
      {show ? <A /> : <B />}
    </div>
  )
}

No <Show> or {#if} — just JavaScript.

List rendering

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().

Async data fetching

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.

Props stay reactive

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.


What Fict Compiles To

// ✍️ 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.


Advanced Features

Error Boundaries

import { ErrorBoundary } from 'fict'
;<ErrorBoundary fallback={err => <p>Error: {String(err)}</p>}>
  <RiskyComponent />
</ErrorBoundary>

Suspense

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

SSR Streaming

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 patches

renderToPartial is an advanced API (Preview in v1.0). Resumable handlers and partial prerendering are active preview work, not a stable Qwik-compatible contract.

fict/plus — Advanced APIs

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.


Control Flow and Branch Reactivity

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 updates

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


Framework Comparison

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

Performance

js-framework-benchmark — 2026-09-14

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.


Status & Roadmap

⚠️ 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.

✅ Completed

  • Compiler with HIR/SSA
  • Stable $state / $effect semantics
  • Automatic derived value inference
  • Explicit $async declarations and owned async graph composition
  • $store in fict, resource/lazy in fict/plus
  • startTransition, useTransition, useDeferredValue in fict
  • Vite plugin
  • ESLint plugin
  • Support sourcemap
  • DevTools
  • Router
  • Testing library
  • SSR / streaming

🗺️ Planned

  • Initial migration guide from React/Vue/Svelte/Solid
  • Framework-specific migration recipes and maintained real-world templates

Documentation

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: true to escalate the FICT-R006 control-flow fallback diagnostic to a build error.
  • strictGuarantee is enabled by default for fail-closed guarantees.
  • Production compilation (NODE_ENV=production) force-enables strictGuarantee even when an integration opts out.
  • Use strictGuarantee: false only for non-production migration inventory. The maintained benchmark requires strict compilation.
  • CI can force strict mode with FICT_STRICT_GUARANTEE=1 during build steps.
  • Guarantee boundary reference: docs/reactivity-guarantee-matrix.md.
  • pnpm test:strict-applications runs the maintained production build/browser corpus. See application coverage, boundary counts and migration evidence.

FAQ

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.


Known Limitations

The compiler has some limitations when handling conditional rendering patterns.

Control-flow patterns supported

  • Multiple sequential if-return branches are compiled into reactive conditionals.
  • if blocks without return are auto-wrapped so reactive side effects still update.
  • Nested branch logic (e.g. inner if/switch) and reactive prelude reads before return are 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.

Acknowledgments

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