Fict is a compiler-first, fine-grained reactive UI framework. This document covers all developer-facing public APIs.
- Quick Start
- Reactivity Core
- Lifecycle
- Scheduling & Control
- DOM Rendering
- Components
- Props Utilities
- Ref
- Context
- Extended API (fict/plus)
- Advanced APIs (fict/advanced)
- Type Definitions
import { $state, $effect, render } from 'fict'
function Counter() {
let count = $state(0)
$effect(() => {
console.log(`Count changed: ${count}`)
})
return <button onClick={() => count++}>Clicked {count} times</button>
}
render(() => <Counter />, document.getElementById('app')!)Declare a reactive state variable. This is a compiler macro that compiles to the underlying signal mechanism.
let value = $state<T>(initialValue: T)Example:
function Counter() {
// Primitives
let count = $state(0)
// Objects
let user = $state({ name: 'Alice', age: 25 })
// Arrays
let items = $state<string[]>([])
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count++}>Increment</button>
<button onClick={() => (user = { ...user, age: user.age + 1 })}>Birthday</button>
<button onClick={() => (items = [...items, 'new item'])}>Add Item</button>
</div>
)
}Notes:
$statecan only be used at the top level of a component function, not inside conditionals or loops.- For objects/arrays, create new references for updates (immutable updates).
- For deep reactivity, use
$store.
Declare a side effect. It re-runs automatically when reactive dependencies change.
$effect(fn: () => void | (() => void))Example:
function Timer() {
let seconds = $state(0)
// Basic effect
$effect(() => {
console.log(`Seconds: ${seconds}`)
})
// Effect with cleanup
$effect(() => {
const id = setInterval(() => seconds++, 1000)
return () => clearInterval(id) // cleanup
})
// Depend on external API
$effect(() => {
document.title = `Timer: ${seconds}s`
})
return <div>Elapsed: {seconds}s</div>
}Notes:
- Cleanup runs before the next execution and when the component is destroyed.
- Cleanup callbacks run in the same flush cycle as the invalidating update. If multiple effects update the same signal in one flush, cleanup reads observe the previous committed snapshot for that flush.
- Avoid
async/awaitinside$effect; for async work, useresource. - Dependencies are tracked automatically; no dependency arrays needed.
Declare an explicit computed value (usually unnecessary because the compiler can infer).
const value = $memo<T>(fn: () => T)Example:
function TodoList() {
let todos = $state([
{ id: 1, text: 'Learn Fict', done: false },
{ id: 2, text: 'Build app', done: true },
])
// Inferred computed values (recommended)
const activeTodos = () => todos.filter(t => !t.done)
const completedCount = () => todos.filter(t => t.done).length
// Explicit $memo (when caching must be enforced)
const expensiveComputation = $memo(() => {
return todos.reduce((acc, t) => acc + t.text.length, 0)
})
return (
<div>
<p>Active: {activeTodos().length}</p>
<p>Completed: {completedCount()}</p>
<p>Total chars: {expensiveComputation}</p>
</div>
)
}Create a deeply reactive proxy that allows direct mutation of nested properties.
This is the canonical user-facing deep store API. Internal createStore
helpers are reserved for compiler/runtime code and are not part of the
application API.
import { $store } from 'fict'
function $store<T extends object>(initialValue: T): TExample:
import { $store } from 'fict'
function NestedState() {
const state = $store({
user: {
name: 'Alice',
settings: {
theme: 'dark',
notifications: true,
},
},
items: [1, 2, 3],
})
return (
<div>
<p>Name: {state.user.name}</p>
<p>Theme: {state.user.settings.theme}</p>
{/* Directly mutate nested props */}
<button onClick={() => (state.user.name = 'Bob')}>Change name</button>
<button onClick={() => (state.user.settings.theme = 'light')}>Toggle theme</button>
<button onClick={() => state.items.push(4)}>Add item</button>
</div>
)
}Difference vs $state:
| Feature | $state |
$store |
|---|---|---|
| Reactive depth | Shallow (immutable updates) | Deep (direct mutation) |
| Syntax | let x = $state(v) |
const x = $store(v) |
| Update style | x = newValue |
x.prop = newValue |
| Best for | Simple values, immutable data | Complex nested objects |
Low-level API: create a reactive signal.
function createSignal<T>(initialValue: T): Signal<T>
type Signal<T> = {
(): T
(value: T): void
}Example:
import { createEffect } from 'fict'
import { createSignal } from 'fict/advanced'
function Counter() {
const count = createSignal(0)
createEffect(() => {
console.log('Count is now:', count())
})
return <button onClick={() => count(count() + 1)}>Count: {count()}</button>
}Note: A signal accessor is both getter and setter. To update from the previous value, read first:
count(count() + 1).
| Choose | When |
|---|---|
$state |
✅ Local component state (default) ✅ Simple local read/write ✅ Concise syntax ( count++) |
createSignal |
✅ Module-level shared state (global store) ✅ Custom hooks that return a signal ✅ Utility / non-component code |
Tip
General rule: use $state inside components, $store for deep shared objects, and createSignal only for advanced scalar/library primitives.
Low-level API: create a cached computed value.
function createMemo<T>(fn: () => T): () => TExample:
import { createMemo } from 'fict'
import { createSignal } from 'fict/advanced'
function FilteredList() {
const items = createSignal([1, 2, 3, 4, 5])
const threshold = createSignal(3)
// Recomputes only when items or threshold change
const filtered = createMemo(() => items().filter(n => n > threshold()))
return (
<div>
<p>Filtered: {filtered().join(', ')}</p>
<button onClick={() => threshold(threshold() - 1)}>Lower threshold</button>
</div>
)
}Low-level API: create a reactive side effect.
function createEffect(fn: () => void | (() => void)): () => voidExample:
import { createEffect } from 'fict'
import { createSignal } from 'fict/advanced'
function Logger() {
const message = createSignal('Hello')
// Returns a disposer
const dispose = createEffect(() => {
console.log('Message:', message())
return () => {
console.log('Cleaning up previous effect')
}
})
// Manually stop the effect if needed
// dispose()
return <input value={message()} onInput={e => message(e.target.value)} />
}Create a selector for fine-grained subscription optimization (e.g., list item selection).
function createSelector<T, U = T>(
source: () => T,
fn?: (a: U, b: T) => boolean,
): (key: U) => booleanExample:
import { createSignal, createSelector } from 'fict/advanced'
function SelectableList() {
const selectedId = createSignal<number | null>(null)
const isSelected = createSelector(selectedId)
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
]
return (
<ul>
{items.map(item => (
<li
key={item.id}
class={isSelected(item.id) ? 'selected' : ''}
onClick={() => selectedId(item.id)}
>
{item.name}
</li>
))}
</ul>
)
}Run synchronously after the component's DOM is committed to its current host or container.
function onMount(fn: () => void | (() => void)): voidExample:
import { onMount } from 'fict'
function Chart() {
let canvasRef: HTMLCanvasElement | null = null
onMount(() => {
// The component's DOM is available in the current render container
const ctx = canvasRef?.getContext('2d')
ctx?.fillRect(0, 0, 100, 100)
// Optional cleanup
return () => {
console.log('Chart unmounted')
}
})
return <canvas ref={el => (canvasRef = el)} />
}Note: A detached render container does not delay
onMount. Its callbacks run beforerender()returns; inserting that container into the document later does not run them again.
Run when the component is destroyed.
function onDestroy(fn: () => void): voidExample:
import { onDestroy, onMount } from 'fict'
function WebSocketComponent() {
let ws: WebSocket | null = null
onMount(() => {
ws = new WebSocket('wss://example.com')
})
onDestroy(() => {
ws?.close()
})
return <div>WebSocket Component</div>
}Run when the current reactive scope cleans up (usable inside an effect).
function onCleanup(fn: () => void): voidExample:
import { $effect, $state, onCleanup } from 'fict'
function Subscription() {
let topic = $state('news')
$effect(() => {
const subscription = subscribe(topic)
onCleanup(() => {
subscription.unsubscribe()
})
})
return <div>Subscribed to: {topic}</div>
}Create an isolated reactive root context.
function createRoot<T>(
fn: () => T,
options?: { inherit?: boolean },
): { value: T; dispose: () => void }Notes:
- By default,
createRootis isolated and does not inherit error/suspense handlers. - Use
{ inherit: true }to link to the current root when you need boundary propagation. - The callback runs without subscribing the caller. Effects belong to the new root and remain active until its disposer is called, even if the creating effect re-runs or stops.
Example:
import { createEffect, createRoot } from 'fict'
import { createSignal } from 'fict/advanced'
// Create reactive state outside components
const { value, dispose } = createRoot(() => {
const count = createSignal(0)
const setCount = (next: number) => count(next)
createEffect(() => {
console.log('Global count:', count())
})
return { count, setCount }
})
// Use global state
function Counter() {
return <button onClick={() => value.setCount(value.count() + 1)}>Global: {value.count()}</button>
}
// Clean up when done
// dispose()Batch multiple updates into a single DOM flush.
function batch<T>(fn: () => T): TExample:
import { batch } from 'fict'
function MultiUpdate() {
let firstName = $state('John')
let lastName = $state('Doe')
let age = $state(25)
const updateAll = () => {
batch(() => {
firstName = 'Jane'
lastName = 'Smith'
age = 30
})
// Only one DOM update
}
return (
<div>
<p>
{firstName} {lastName}, {age}
</p>
<button onClick={updateAll}>Update All</button>
</div>
)
}Skip dependency tracking within a block.
function untrack<T>(fn: () => T): TExample:
import { untrack } from 'fict'
function Logger() {
let count = $state(0)
let logEnabled = $state(true)
$effect(() => {
// Track only count, not logEnabled
if (untrack(() => logEnabled)) {
console.log(`Count: ${count}`)
}
})
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count++}>Increment</button>
<button onClick={() => (logEnabled = !logEnabled)}>Toggle logging</button>
</div>
)
}Mark updates as low-priority transitions.
function startTransition(fn: () => void): voidExample:
import { startTransition } from 'fict'
function Search() {
let query = $state('')
let results = $state<string[]>([])
const handleInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value
query = value
// Mark search result updates as low priority
startTransition(() => {
results = performSearch(value)
})
}
return (
<div>
<input value={query} onInput={handleInput} />
<ul>
{results.map(r => (
<li key={r}>{r}</li>
))}
</ul>
</div>
)
}Get the transition state and a start function.
function useTransition(): [
isPending: () => boolean,
startTransition: (fn: () => void | PromiseLike<unknown>) => void,
]Example:
import { useTransition } from 'fict'
function HeavyUpdate() {
let data = $state<Item[]>([])
const [isPending, startTransition] = useTransition()
const loadMore = () => {
startTransition(() => {
data = [...data, ...generateManyItems()]
})
}
return (
<div>
{isPending() && <div class="spinner">Loading...</div>}
<button onClick={loadMore} disabled={isPending()}>
Load More
</button>
<List items={data} />
</div>
)
}Create a value that updates with lower priority.
function useDeferredValue<T>(value: () => T): () => TExample:
import { useDeferredValue } from 'fict'
function SearchResults() {
let query = $state('')
const deferredQuery = useDeferredValue(() => query)
// query updates immediately (fast input)
// deferredQuery updates later (lower priority render)
const isStale = () => query !== deferredQuery()
return (
<div>
<input value={query} onInput={e => (query = e.target.value)} />
<div style={{ opacity: isStale() ? 0.5 : 1 }}>
<ExpensiveList query={deferredQuery()} />
</div>
</div>
)
}Render a component into a DOM container.
function render(view: () => FictNode, container: HTMLElement): () => void // returns unmount functionExample:
import { render } from 'fict'
function App() {
return <div>Hello Fict!</div>
}
// Mount
const unmount = render(() => <App />, document.getElementById('app')!)
// Unmount
// unmount()Attach client ownership to completed, compatible server output. Exported by
fict and @fictjs/runtime; returns an unmount function that cancels owned work.
function hydrate(
view: () => FictNode,
container: HTMLElement,
options?: {
strictHydration?: boolean
onHydrationIssue?: (issue: HydrationIssue) => void
},
): () => voidFinish streamed patches before hydration and provide matching initial application data. Default mode reports and repairs mismatches; strict mode throws and disposes the attempted client graph. See async SSR and hydration for pending inputs, request isolation and the Preview serialization boundary.
Return multiple elements without creating an extra DOM node.
import { Fragment } from 'fict'
function MultipleElements() {
return (
<Fragment>
<h1>Title</h1>
<p>Paragraph</p>
</Fragment>
)
}
// Or shorthand
function MultipleElements() {
return (
<>
<h1>Title</h1>
<p>Paragraph</p>
</>
)
}Capture errors in the child component tree.
function ErrorBoundary(props: {
fallback: FictNode | ((error: unknown, reset: () => void) => FictNode)
onError?: (error: unknown) => void
resetKeys?: unknown | (() => unknown)
children: FictNode
}): FictNodeExample:
import { ErrorBoundary } from 'fict'
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div class="error">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<RiskyComponent />
</ErrorBoundary>
)
}
function RiskyComponent() {
let shouldError = $state(false)
if (shouldError) {
throw new Error('Oops!')
}
return <button onClick={() => (shouldError = true)}>Trigger Error</button>
}Notes:
fallbackcan be a node or a function. If a function is provided, it receives the error and areset()callback to retry rendering the children.onErrorruns when the boundary captures an error.resetKeyscan be a value or an explicitly marked getter; when it changes, the boundary resets. JSX source getters are marked by the compiler, while hand-authored runtime objects should usereactive(fn).- SSR behavior:
renderToString: fallback HTML is rendered when errors are captured.- Streaming (
mode: 'shell'): handled errors continue the stream; unhandled async errors callonErrorand abort.
Handle async loading states.
function Suspense(props: {
fallback: FictNode | ((error?: unknown) => FictNode)
onResolve?: () => void
onReject?: (error: unknown) => void
resetKeys?: unknown | (() => unknown)
children: FictNode
}): FictNodeExample:
import { Suspense } from 'fict'
import { reactive } from 'fict/advanced'
import { lazy, resource } from 'fict/plus'
// Lazy component
const HeavyComponent = lazy(() => import('./HeavyComponent'))
// Async resource
const userResource = resource({
fetch: async ({ signal }, id: string) => {
const res = await fetch(`/api/users/${id}`, { signal })
return res.json()
},
suspense: true,
})
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
<UserProfile id="123" />
</Suspense>
)
}
function UserProfile(props: { id: string }) {
const user = userResource.read(reactive(() => props.id))
return (
<div>
<h2>{user.data?.name}</h2>
<p>{user.data?.email}</p>
</div>
)
}Tip
In day-to-day work, just use mergeProps(). The compiler handles reactive props passing; prop() is only needed for edge cases.
| Function | Purpose | Frequency |
|---|---|---|
mergeProps() |
Merge defaults / override props | Common |
prop() |
Mark reactive getter + cache | Rare |
Merge multiple props objects while keeping them reactive.
function mergeProps<T extends object[]>(...sources: T): MergedProps<T>Example:
import { mergeProps } from 'fict'
function Button(props: { class?: string; onClick?: () => void; children?: FictNode }) {
const merged = mergeProps(
{ class: 'btn', type: 'button' }, // defaults
props, // user input
{ 'data-component': 'Button' }, // forced overrides
)
return (
<button
class={merged.class}
type={merged.type}
onClick={merged.onClick}
data-component={merged['data-component']}
>
{merged.children}
</button>
)
}Mark a reactive getter and cache it (rare). Use when the compiler cannot detect reactive fields in dynamically built objects.
import { prop } from 'fict'
function prop<T>(getter: () => T, options?: { unwrap?: boolean }): () => TNotes:
- If the getter returns another prop getter,
prop()unwraps it by default. - Use
{ unwrap: false }when you need to pass a prop getter through as a value.
When to use:
- Dynamically built props objects (compiler cannot statically analyze)
- Cache expensive prop computation
Example 1: Dynamic props
import { prop } from 'fict'
function getDynamicPayload() {
// Built at runtime; compiler cannot statically analyze
return {
theme: prop(() => currentTheme),
user: prop(() => currentUser),
staticFlag: true,
}
}
return <Dashboard {...getDynamicPayload()} />Example 2: Cache expensive computation
import { prop } from 'fict'
function DataTable({ list, filter }: Props) {
// Recompute only when list or filter changes
const memoizedData = prop(() => expensiveFilter(list, filter))
return <Table data={memoizedData} />
}Example 3: Forward a prop getter
import { prop } from 'fict'
function Wrapper(props: { getValue: () => number }) {
// Pass the getter through as-is
const forwarded = prop(() => props.getValue, { unwrap: false })
return <Child getValue={forwarded} />
}Create a DOM element reference.
function createRef<T extends Element = HTMLElement>(): RefObject<T>
interface RefObject<T> {
current: T | null
}Example:
import { createRef, onMount } from 'fict'
function FocusInput() {
const inputRef = createRef<HTMLInputElement>()
onMount(() => {
inputRef.current?.focus()
})
return <input ref={inputRef} placeholder="Auto focused" />
}
// Callback ref
function CallbackRef() {
let input: HTMLInputElement | null = null
onMount(() => {
input?.focus()
})
return <input ref={el => (input = el)} placeholder="Auto focused" />
}The Context API passes data through the component tree without prop drilling. Use it for:
- Theme configuration
- Internationalization settings
- User authentication state
- SSR isolation
- Multi-instance support
Create a context object.
import { createContext } from 'fict'
// or
import { createContext } from 'fict/advanced'
function createContext<T>(defaultValue: T): Context<T>Example:
import { createContext } from 'fict'
// Theme context
const ThemeContext = createContext<'light' | 'dark'>('light')
// User context
const UserContext = createContext<{ name: string; role: string } | null>(null)Read the current nearest Provider value.
function useContext<T>(context: Context<T>): TExample:
import { createContext, useContext } from 'fict'
const ThemeContext = createContext('light')
function ThemedButton() {
const theme = useContext(ThemeContext)
return <button class={theme === 'dark' ? 'btn-dark' : 'btn-light'}>Click me</button>
}
function App() {
return (
<ThemeContext.Provider value="dark">
<ThemedButton />
</ThemeContext.Provider>
)
}useContext() participates in tracking when it is called inside an effect,
memo, or reactive binding. A primitive stored during one-time component setup is
a snapshot; for backward compatibility, Providers replay subtrees containing
such snapshots when value changes. Use useContextAccessor() to update the
value without remounting that subtree.
Return a read-only reactive accessor for the nearest Provider value.
function useContextAccessor<T>(context: Context<T>): () => TProvider value updates publish through this accessor without remounting the subtree, so descendant state, DOM identity, focus, and scroll are preserved.
Check whether a context is provided in the current tree.
function hasContext<T>(context: Context<T>): booleanExample:
import { createContext, hasContext, useContext } from 'fict'
const OptionalFeatureContext = createContext<{ enabled: boolean } | null>(null)
function FeatureComponent() {
if (hasContext(OptionalFeatureContext)) {
const feature = useContext(OptionalFeatureContext)
return <div>Feature enabled: {feature?.enabled}</div>
}
return <div>Feature not available</div>
}Providers can be nested; inner providers override outer values:
const ThemeContext = createContext('light')
function App() {
return (
<ThemeContext.Provider value="dark">
<Header /> {/* uses dark */}
<ThemeContext.Provider value="light">
<Sidebar /> {/* uses light */}
</ThemeContext.Provider>
<Content /> {/* uses dark */}
</ThemeContext.Provider>
)
}Use useContextAccessor() when the Provider value itself changes:
import { $state, createContext, useContextAccessor } from 'fict'
const ThemeContext = createContext<'light' | 'dark'>('light')
function ThemeLabel() {
const theme = useContextAccessor(ThemeContext)
return <span data-theme={theme()}>{theme()}</span>
}
function App() {
let theme = $state<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={theme}>
<button onClick={() => (theme = theme === 'light' ? 'dark' : 'light')}>Toggle</button>
<ThemeLabel />
</ThemeContext.Provider>
)
}For service objects, passing a stable signal or store inside the context value remains useful: consumers can call those getters directly and avoid replacing the Provider value object.
APIs imported from fict/plus for async resource management and lazy loading.
Create an async resource with caching, refreshing, and Suspense support.
import { reactive } from 'fict/advanced'
import { resource } from 'fict/plus'
interface ResourceResult<T> {
readonly data: T | undefined
readonly loading: boolean
readonly error: unknown
refresh: () => void
}
function resource<T, Args = void>(
optionsOrFetcher:
| ((ctx: { signal: AbortSignal }, args: Args) => Promise<T>)
| ResourceOptions<T, Args>,
): {
read(args: (() => Args) | Args): ResourceResult<T>
invalidate(key?: unknown): void
prefetch(args: Args, keyOverride?: unknown): void
mutate(
args: (() => Args) | Args,
value: T | ((prev: T | undefined) => T),
options?: { key?: unknown; revalidate?: boolean },
): void
}Function args are treated as ordinary values unless they are explicitly marked with reactive(fn). This lets resources accept callback/function arguments without invoking them accidentally.
Basic example:
import { resource } from 'fict/plus'
// Simple usage
const postsResource = resource(async ({ signal }) => {
const res = await fetch('/api/posts', { signal })
return res.json()
})
function PostList() {
const posts = postsResource.read()
if (posts.loading) return <div>Loading...</div>
if (posts.error) return <div>Error: {String(posts.error)}</div>
return (
<ul>
{posts.data?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}With params and cache:
import { resource } from 'fict/plus'
const userResource = resource({
fetch: async ({ signal }, userId: string) => {
const res = await fetch(`/api/users/${userId}`, { signal })
if (!res.ok) throw new Error('Failed to fetch user')
return res.json()
},
cache: {
mode: 'memory',
ttlMs: 5 * 60 * 1000, // 5 minutes
staleWhileRevalidate: true, // return stale data while refreshing
},
})
function UserCard(props: { userId: string }) {
// Explicit reactive parameter
const user = userResource.read(reactive(() => props.userId))
return (
<div>
{user.loading && <span>Refreshing...</span>}
<h2>{user.data?.name}</h2>
<button onClick={user.refresh}>Refresh</button>
</div>
)
}With Suspense:
import { resource } from 'fict/plus'
import { Suspense } from 'fict'
const dataResource = resource({
fetch: async ({ signal }) => {
const res = await fetch('/api/data', { signal })
return res.json()
},
suspense: true, // enable Suspense mode
})
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<DataDisplay />
</Suspense>
)
}
function DataDisplay() {
const data = dataResource.read()
// In Suspense mode, data.data is guaranteed
return <div>{data.data.title}</div>
}Lazy-load components for code splitting.
import { lazy } from 'fict/plus'
interface LazyOptions {
maxRetries?: number
retryDelay?: number
}
interface LazyComponent<TProps extends Record<string, unknown>> extends Component<TProps> {
reset: () => void
preload: () => Promise<void>
}
function lazy<TProps extends Record<string, unknown> = Record<string, unknown>>(
loader: () => Promise<{ default: Component<TProps> }>,
options?: LazyOptions,
): LazyComponent<TProps>Example:
import { lazy } from 'fict/plus'
import { Suspense } from 'fict'
// Lazy components
const HeavyChart = lazy(() => import('./components/HeavyChart'))
const AdminPanel = lazy(() => import('./components/AdminPanel'))
function App() {
let showAdmin = $state(false)
return (
<div>
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
<button onClick={() => (showAdmin = !showAdmin)}>Toggle Admin</button>
{showAdmin && (
<Suspense fallback={<div>Loading admin panel...</div>}>
<AdminPanel />
</Suspense>
)}
</div>
)
}These APIs are for power users and library authors. They provide lower-level control over Fict's internals.
Mark a manual zero-argument getter as reactive. Plain zero-argument functions are treated as function values/callbacks, not reactive getters.
import { isReactive, nonReactive, reactive, unwrap } from 'fict/advanced'
function reactive<T>(fn: () => T): () => T
function nonReactive<T extends (...args: unknown[]) => unknown>(fn: T): T
function isReactive(value: unknown): value is () => unknown
function unwrap<T>(value: T | (() => T)): TExample:
const value = reactive(() => source())
isReactive(value) // true
isReactive(() => source()) // false
unwrap(value) // source()The compiler marks generated getters automatically. Use reactive(fn) only when hand-authoring low-level runtime objects or advanced bindings.
nonReactive(fn) remains available for advanced interop, but ordinary component callback props should not need it.
Create an explicit reactive scope that can be re-run and disposed manually.
The scope retains the root in which it was created, including its context and error boundaries, even when run is called later. Each run replaces the previous one. Stopping during initialization cleans up that run; a replacement started reentrantly by cleanup remains owned by the scope.
After the owning root is destroyed, run still returns the callback's result, but created effects and cleanup are disposed immediately and mount callbacks are skipped.
import { createScope } from 'fict/advanced'
interface ReactiveScope {
run<T>(fn: () => T): T
stop(): void
}
function createScope(): ReactiveScopeExample:
const scope = createScope()
scope.run(() => {
createEffect(() => {
/* reactive work */
})
})
// Later
scope.stop()Run a block of reactive code behind a boolean flag. When the flag becomes false, the scope is disposed and all effects/memos created inside are cleaned up.
import { reactive, runInScope } from 'fict/advanced'
function runInScope(flag: boolean | (() => boolean), fn: () => void): voidExample:
runInScope(
reactive(() => show),
() => {
createEffect(() => {
/* ... */
})
},
)Create an effect scope that collects reactive subscriptions created during fn and returns a disposer.
import { effectScope } from 'fict/advanced'
function effectScope(fn: () => void): () => voidExample:
const dispose = effectScope(() => {
createEffect(() => {
/* ... */
})
})
// Later
dispose()Expose the runtime hook protocol contract used by DevTools and third-party inspectors.
import {
FICT_DEVTOOLS_MIN_PROTOCOL_VERSION,
FICT_DEVTOOLS_PROTOCOL_VERSION,
getDevtoolsHook,
isDevtoolsHookCompatible,
type FictDevtoolsCompatibility,
type FictDevtoolsHook,
} from 'fict/advanced'
interface FictDevtoolsCompatibility {
protocolVersion: number
minRuntimeProtocol: number
maxRuntimeProtocol: number
}
function getDevtoolsHook(): FictDevtoolsHook | undefined
function isDevtoolsHookCompatible(hook: FictDevtoolsHook): booleangetDevtoolsHook() only returns hooks compatible with the current runtime protocol.
Configure cycle protection thresholds and enablement. This helps tune cycle detection sensitivity for your application.
import { setCycleProtectionOptions } from 'fict/advanced'
interface CycleProtectionOptions {
enabled?: boolean // Default: dev-only (NODE_ENV !== 'production')
maxFlushCyclesPerMicrotask?: number // Default: 10,000
maxEffectRunsPerFlush?: number // Default: 20,000
windowSize?: number // Default: 5
highUsageRatio?: number // Default: 0.8
maxRootReentrantDepth?: number // Default: 10
enableWindowWarning?: boolean // Default: true
devMode?: boolean // Default: NODE_ENV !== 'production' (throw in dev, warn in prod)
enableBackoffWarning?: boolean // Default: dev-only true (warn at threshold)
backoffWarningRatio?: number // Default: 0.5
}
function setCycleProtectionOptions(options: CycleProtectionOptions): voidExample:
import { setCycleProtectionOptions } from 'fict/advanced'
// For large applications
setCycleProtectionOptions({
maxFlushCyclesPerMicrotask: 50000,
})
// Strict mode for testing
setCycleProtectionOptions({
devMode: true,
maxFlushCyclesPerMicrotask: 100,
})Note: Cycle protection is enabled by default only in development mode. In production it is disabled by default, but you can opt-in via
setCycleProtectionOptions({ enabled: true }).
For detailed documentation, see Cycle Protection.
// Renderable node types
type FictNode = FictVNode | FictNode[] | Node | string | number | boolean | null | undefined
// Virtual node
interface FictVNode {
type: string | symbol | Component
props: Record<string, unknown> | null
key?: string | undefined
}
// Component type
type Component<P = {}> = (props: P & BaseProps) => FictNode
interface BaseProps {
key?: string | number
children?: FictNode | FictNode[]
}
type PropsWithChildren<P = {}> = P & {
children?: FictNode | FictNode[]
}// Signal type
type Signal<T> = {
(): T
(value: T): void
}
// Memo type
type Memo<T> = () => T
// Maybe reactive value
type MaybeReactive<T> = T | (() => T)
// Cleanup function
type Cleanup = () => void// Style value
type StyleValue = string | number
// CSS style object
type StyleProp = string | Record<string, StyleValue> | null | undefined
// Class attribute
type ClassProp = string | Record<string, boolean> | null | undefinedtype RefCallback<T extends Element> = (element: T) => void
interface RefObject<T extends Element> {
current: T | null
}
type Ref<T extends Element> = RefCallback<T> | RefObject<T>type EventHandler<E extends Event = Event> = (event: E) => voidFict uses fine-grained keyed list reconciliation for efficient list updates.
When rendering arrays, provide a unique key prop for each item:
function TodoList() {
let todos = $state([
{ id: 1, text: 'Learn Fict' },
{ id: 2, text: 'Build app' },
])
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}Key Requirements:
| Requirement | Description |
|---|---|
| Unique | Keys must be unique within the same list |
| Stable | Use stable identifiers (e.g., database IDs), not array indices |
| Type | Keys can be strings or numbers |
Duplicate Key Behavior:
If duplicate keys are detected in the same list:
- Development mode: A console warning is logged
- Behavior: The last item with a duplicate key replaces the previous one
- Impact: This may cause unexpected UI behavior and state loss
// ⚠️ Bad: Duplicate keys
{
items.map(item => (
<li key={item.category}>{item.name}</li> // Multiple items may share category
))
}
// ✅ Good: Unique keys
{
items.map(item => <li key={item.id}>{item.name}</li>)
}Warning: Always ensure keys are unique. Duplicate keys can cause items to be unexpectedly removed or replaced, leading to data loss and incorrect rendering.
import { $state, $effect, render, onMount } from 'fict'
interface Todo {
id: number
text: string
completed: boolean
}
function TodoApp() {
let todos = $state<Todo[]>([])
let inputText = $state('')
let filter = $state<'all' | 'active' | 'completed'>('all')
let nextId = 1
// Load from localStorage
onMount(() => {
const saved = localStorage.getItem('todos')
if (saved) {
todos = JSON.parse(saved)
nextId = Math.max(...todos.map(t => t.id), 0) + 1
}
})
// Save to localStorage
$effect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
})
// Computed values
const filteredTodos = () => {
switch (filter) {
case 'active':
return todos.filter(t => !t.completed)
case 'completed':
return todos.filter(t => t.completed)
default:
return todos
}
}
const activeCount = () => todos.filter(t => !t.completed).length
const completedCount = () => todos.filter(t => t.completed).length
// Actions
const addTodo = () => {
if (!inputText.trim()) return
todos = [
...todos,
{
id: nextId++,
text: inputText.trim(),
completed: false,
},
]
inputText = ''
}
const toggleTodo = (id: number) => {
todos = todos.map(t => (t.id === id ? { ...t, completed: !t.completed } : t))
}
const removeTodo = (id: number) => {
todos = todos.filter(t => t.id !== id)
}
const clearCompleted = () => {
todos = todos.filter(t => !t.completed)
}
return (
<div class="todo-app">
<h1>Todos</h1>
{/* Input */}
<div class="input-section">
<input
value={inputText}
onInput={e => (inputText = (e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
/>
<button onClick={addTodo}>Add</button>
</div>
{/* List */}
<ul class="todo-list">
{filteredTodos().map(todo => (
<li key={todo.id} class={todo.completed ? 'completed' : ''}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
{/* Footer */}
{todos.length > 0 && (
<div class="footer">
<span>{activeCount()} items left</span>
<div class="filters">
<button class={filter === 'all' ? 'active' : ''} onClick={() => (filter = 'all')}>
All
</button>
<button class={filter === 'active' ? 'active' : ''} onClick={() => (filter = 'active')}>
Active
</button>
<button
class={filter === 'completed' ? 'active' : ''}
onClick={() => (filter = 'completed')}
>
Completed
</button>
</div>
{completedCount() > 0 && <button onClick={clearCompleted}>Clear completed</button>}
</div>
)}
</div>
)
}
render(() => <TodoApp />, document.getElementById('app')!)Server-side rendering utilities live in the @fictjs/ssr package.
import { renderToString } from '@fictjs/ssr'
function renderToString(view: () => FictNode, options?: RenderToStringOptions): stringSupported SSR does not emit the Preview snapshot protocol by default. Setting
includeSnapshot: true opts into resumability and must be paired with the
experimental loader and a resumable compiler build.
scopeIdentifierPrefix supplies a stable namespace for resumable
data-fict-s and snapshot scope IDs. Every render gets an automatic unique
namespace when it is omitted. Explicit prefixes must be unique among outputs
combined into one document and do not affect streaming Suspense patch IDs.
Shell-first streaming (Web Streams):
import { renderToStream } from '@fictjs/ssr'
function renderToStream(
view: () => FictNode,
options?: RenderToStreamOptions,
): ReadableStream<Uint8Array>Key options:
mode: 'shell' | 'all'— shell-first streaming vs all-readyincludeSnapshot: true— explicit Preview resumability opt-in; default falsesnapshotTarget: 'head' | 'body' | 'container'— Preview snapshot placement; shell streaming with an external runtime and incremental head snapshots requires a non-emptyscriptNonce, while nonce-free strict-CSP routes usecontainerorbodyscriptNonce— nonce for generated scriptsscopeIdentifierPrefix— stable resumable scope namespace shared by all SSR render entry points; it must be unique within a composed documentstreamRuntime: 'inline' | 'external'— external mode expectsstreamRuntimeSrcto serve the published@fictjs/ssr/fict-stream-runtime.jsassetstreamIdentifierPrefix— stable Suspense patch namespace for cached or independently rendered streams that may share one document; it must be unique within that document and does not alter resumable scope IDs
import { createStreamRuntimeCode } from '@fictjs/ssr/stream-runtime'@fictjs/ssr/fict-stream-runtime.js is the packaged classic script asset for
strict CSP streaming routes.
External observer mode emits no executable inline patch scripts. When Preview snapshots are also enabled, container/body snapshots remain non-executable JSON; incremental head placement uses a nonce-bearing inline mover and is rejected when no non-empty nonce is configured.
Preview — partial prerendering has no semver guarantee and does not block Core 1.0. Use supported
renderToStreamwhen the PPR return shape is not acceptable as an experimental dependency.
Partial prerendering returns a complete shell HTML plus a deferred patch stream. The return shape is not part of the stable v1 API freeze.
import { renderToPartial } from '@fictjs/ssr/experimental'
function renderToPartial(
view: () => FictNode,
options?: RenderToStreamOptions,
): {
shell: string
stream: ReadableStream<Uint8Array>
shellReady: Promise<void>
allReady: Promise<void>
abort: (reason?: unknown) => void
}Node.js streaming variant:
import { renderToPipeableStream } from '@fictjs/ssr'
function renderToPipeableStream(
view: () => FictNode,
options?: RenderToStreamOptions,
): {
pipe: (writable: NodeJS.WritableStream) => void
abort: (reason?: unknown) => void
shellReady: Promise<void>
allReady: Promise<void>
}For Edge runtimes, prefer renderToStream(). Use renderToPartial() from
@fictjs/ssr/experimental only for Preview PPR workflows.
Preview — the client loader has no semver guarantee, is excluded from the Core 1.0 promise, and requires explicit compiler and SSR snapshot opt-ins.
The client loader is exported from fict/experimental/loader and
@fictjs/runtime/experimental/loader:
import { installResumableLoader } from 'fict/experimental/loader'
function installResumableLoader(options?: ResumableLoaderOptions): void
interface ResumableLoaderOptions {
document?: Document
snapshotScriptId?: string
events?: string[]
snapshotMigrations?: Record<number, SnapshotMigration>
onSnapshotIssue?: (issue: SnapshotIssue) => void
onSnapshotRejected?: (issue: SnapshotIssue) => void | Promise<void>
prefetch?: PrefetchStrategy | false
}
type SnapshotMigration = (
snapshot: Record<string, unknown>,
context: {
fromVersion: number
toVersion: number
source: string
},
) => unknown
type LegacySnapshotFormat = 'raw-props' | 'encoded-props'
function createLegacySnapshotMigration(format: LegacySnapshotFormat): SnapshotMigration
const UNVERSIONED_SNAPSHOT_MIGRATION_KEY: 0
type SnapshotIssueCode =
| 'snapshot_parse_error'
| 'snapshot_invalid_shape'
| 'snapshot_unsupported_version'
| 'snapshot_migration_failed'
| 'snapshot_fallback_failed'
| 'scope_snapshot_missing'
| 'resume_import_failed'
| 'resume_function_missing'
| 'resume_failed'
| 'handler_import_failed'
| 'handler_missing'
| 'handler_failed'
interface SnapshotIssue {
code: SnapshotIssueCode
message: string
source: string
expectedVersion: number
actualVersion?: number
scopeId?: string
qrl?: string
url?: string
exportName?: string
eventType?: string
error?: unknown
}
interface PrefetchStrategy {
visibility?: boolean
visibilityMargin?: string
hover?: boolean
hoverDelay?: number
}The current writer emits schema v2. Missing v and v1 snapshots are rejected
unless the application explicitly installs the migration matching its deployed
legacy writer. Format detection is intentionally not heuristic.
onSnapshotIssue reports diagnostics only. If onSnapshotRejected is
configured, the loader disengages before invoking it once; the application is
responsible for mounting its CSR root. Callback failure is reported as
snapshot_fallback_failed. Without this callback, Fict never mounts CSR
automatically.
See SSR / Resume Stability Contract for the historical writer map, complete issue-code contract, and deployment rules.
- Architecture - Deep dive into Fict's execution model
- Reactivity Semantics - Detailed rules of the reactive system
- Compiler Spec - Compiler transformation rules
- SSR / Resume Stability Contract - Behavior, compatibility, security, and budget guarantees
- Framework Comparison - Comparison with React, Solid, Vue, Svelte