Skip to content

Latest commit

 

History

History
2111 lines (1581 loc) · 47.8 KB

api-reference.md

File metadata and controls

2111 lines (1581 loc) · 47.8 KB

Fict API Reference

Fict is a compiler-first, fine-grained reactive UI framework. This document covers all developer-facing public APIs.

Table of Contents


Quick Start

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')!)

Reactivity Core

$state

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:

  • $state can 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.

$effect

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/await inside $effect; for async work, use resource.
  • Dependencies are tracked automatically; no dependency arrays needed.

$memo

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

$store

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): T

Example:

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

createSignal

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

Choosing $state vs createSignal

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.


createMemo

Low-level API: create a cached computed value.

function createMemo<T>(fn: () => T): () => T

Example:

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

createEffect

Low-level API: create a reactive side effect.

function createEffect(fn: () => void | (() => void)): () => void

Example:

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

createSelector

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) => boolean

Example:

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

Lifecycle

onMount

Run synchronously after the component's DOM is committed to its current host or container.

function onMount(fn: () => void | (() => void)): void

Example:

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 before render() returns; inserting that container into the document later does not run them again.


onDestroy

Run when the component is destroyed.

function onDestroy(fn: () => void): void

Example:

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

onCleanup

Run when the current reactive scope cleans up (usable inside an effect).

function onCleanup(fn: () => void): void

Example:

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

createRoot

Create an isolated reactive root context.

function createRoot<T>(
  fn: () => T,
  options?: { inherit?: boolean },
): { value: T; dispose: () => void }

Notes:

  • By default, createRoot is 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()

Scheduling & Control

batch

Batch multiple updates into a single DOM flush.

function batch<T>(fn: () => T): T

Example:

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

untrack

Skip dependency tracking within a block.

function untrack<T>(fn: () => T): T

Example:

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

startTransition

Mark updates as low-priority transitions.

function startTransition(fn: () => void): void

Example:

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

useTransition

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

useDeferredValue

Create a value that updates with lower priority.

function useDeferredValue<T>(value: () => T): () => T

Example:

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

DOM Rendering

render

Render a component into a DOM container.

function render(view: () => FictNode, container: HTMLElement): () => void // returns unmount function

Example:

import { render } from 'fict'

function App() {
  return <div>Hello Fict!</div>
}

// Mount
const unmount = render(() => <App />, document.getElementById('app')!)

// Unmount
// unmount()

hydrate

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
  },
): () => void

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

Fragment

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

Components

ErrorBoundary

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

Example:

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:

  • fallback can be a node or a function. If a function is provided, it receives the error and a reset() callback to retry rendering the children.
  • onError runs when the boundary captures an error.
  • resetKeys can 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 use reactive(fn).
  • SSR behavior:
    • renderToString: fallback HTML is rendered when errors are captured.
    • Streaming (mode: 'shell'): handled errors continue the stream; unhandled async errors call onError and abort.

Suspense

Handle async loading states.

function Suspense(props: {
  fallback: FictNode | ((error?: unknown) => FictNode)
  onResolve?: () => void
  onReject?: (error: unknown) => void
  resetKeys?: unknown | (() => unknown)
  children: FictNode
}): FictNode

Example:

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

Props Utilities

Tip

In day-to-day work, just use mergeProps(). The compiler handles reactive props passing; prop() is only needed for edge cases.

Core API

Function Purpose Frequency
mergeProps() Merge defaults / override props Common
prop() Mark reactive getter + cache Rare

mergeProps

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

prop

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 }): () => T

Notes:

  • 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:

  1. Dynamically built props objects (compiler cannot statically analyze)
  2. 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} />
}

Ref

createRef

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

Context

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

createContext

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)

useContext

Read the current nearest Provider value.

function useContext<T>(context: Context<T>): T

Example:

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.


useContextAccessor

Return a read-only reactive accessor for the nearest Provider value.

function useContextAccessor<T>(context: Context<T>): () => T

Provider value updates publish through this accessor without remounting the subtree, so descendant state, DOM identity, focus, and scroll are preserved.


hasContext

Check whether a context is provided in the current tree.

function hasContext<T>(context: Context<T>): boolean

Example:

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

Nested Providers

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

Reactive Context Values

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.


Extended API (fict/plus)

APIs imported from fict/plus for async resource management and lazy loading.

resource

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

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

Advanced APIs (fict/advanced)

These APIs are for power users and library authors. They provide lower-level control over Fict's internals.

reactive

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)): T

Example:

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.


createScope

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(): ReactiveScope

Example:

const scope = createScope()

scope.run(() => {
  createEffect(() => {
    /* reactive work */
  })
})

// Later
scope.stop()

runInScope

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): void

Example:

runInScope(
  reactive(() => show),
  () => {
    createEffect(() => {
      /* ... */
    })
  },
)

effectScope

Create an effect scope that collects reactive subscriptions created during fn and returns a disposer.

import { effectScope } from 'fict/advanced'

function effectScope(fn: () => void): () => void

Example:

const dispose = effectScope(() => {
  createEffect(() => {
    /* ... */
  })
})

// Later
dispose()

DevTools protocol helpers

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): boolean

getDevtoolsHook() only returns hooks compatible with the current runtime protocol.


setCycleProtectionOptions

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): void

Example:

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.


Type Definitions

Core Types

// 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[]
}

Reactive Types

// 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 Types

// 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 | undefined

Ref Types

type RefCallback<T extends Element> = (element: T) => void

interface RefObject<T extends Element> {
  current: T | null
}

type Ref<T extends Element> = RefCallback<T> | RefObject<T>

Event Handling

type EventHandler<E extends Event = Event> = (event: E) => void

List Rendering

Fict uses fine-grained keyed list reconciliation for efficient list updates.

Keys

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:

  1. Development mode: A console warning is logged
  2. Behavior: The last item with a duplicate key replaces the previous one
  3. 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.


Full Example: Todo App

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')!)

SSR (package: @fictjs/ssr)

Server-side rendering utilities live in the @fictjs/ssr package.

renderToString

import { renderToString } from '@fictjs/ssr'

function renderToString(view: () => FictNode, options?: RenderToStringOptions): string

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

renderToStream

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-ready
  • includeSnapshot: true — explicit Preview resumability opt-in; default false
  • snapshotTarget: 'head' | 'body' | 'container' — Preview snapshot placement; shell streaming with an external runtime and incremental head snapshots requires a non-empty scriptNonce, while nonce-free strict-CSP routes use container or body
  • scriptNonce — nonce for generated scripts
  • scopeIdentifierPrefix — stable resumable scope namespace shared by all SSR render entry points; it must be unique within a composed document
  • streamRuntime: 'inline' | 'external' — external mode expects streamRuntimeSrc to serve the published @fictjs/ssr/fict-stream-runtime.js asset
  • streamIdentifierPrefix — 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

Stream Runtime Asset

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.

renderToPartial

Preview — partial prerendering has no semver guarantee and does not block Core 1.0. Use supported renderToStream when 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
}

renderToPipeableStream

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.

installResumableLoader

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.


More Resources