@fictjs/react
v0.3.0
Published
React interoperability layer for Fict
Readme
@fictjs/react
React interoperability layer for Fict — embed React components inside Fict applications as controlled islands with SSR, lazy loading, and fine-grained prop reactivity.
Why
Fict uses its own compiler-driven reactivity model. When you need to reuse an existing React component (a design system, a charting library, a rich text editor), @fictjs/react bridges the gap: the React subtree runs in its own React root while the surrounding Fict app feeds it reactive props.
Features
| Capability | API | When to use |
| ---------------------- | --------------------- | ---------------------------------------------------------- |
| Eager wrapping | reactify | The React component is already imported |
| Declarative island | ReactIsland | Inline island with a props getter |
| Resumable / lazy | reactify$ | The component should be lazy-loaded via QRL |
| Static loader | installReactIslands | Mount islands from plain HTML attributes (no Fict runtime) |
| Serializable callbacks | reactAction$ | Pass Fict actions across the serialization boundary |
| Vite preset | fictReactPreset | Isolate React JSX transform from Fict's compiler |
Install
pnpm add @fictjs/react @fictjs/runtime react react-domFor the Vite preset (optional):
pnpm add -D @vitejs/plugin-react viteRequirements
- Node 20+
- React 18.2+ or 19
@fictjs/runtime>= 0.10.0
Quick Start
1. Vite Configuration
If your project mixes Fict and React files, use the preset to scope the React JSX transform to a specific directory (default: src/react/**):
// vite.config.ts
import { defineConfig } from 'vite'
import { fictReactPreset } from '@fictjs/react/preset'
import fict from '@fictjs/vite-plugin'
export default defineConfig({
plugins: [fict(), ...fictReactPreset()],
})Custom scope:
fictReactPreset({
include: [/components\/react\/.*\.[jt]sx?$/],
})2. Wrap a React Component (Eager)
import { reactify } from '@fictjs/react'
import { prop } from '@fictjs/runtime'
import { createSignal } from '@fictjs/runtime/advanced'
import { MyButton } from './react/MyButton'
const FictButton = reactify(MyButton)
// In a Fict component
function App() {
const count = createSignal(0)
return <FictButton label={prop(() => `Clicked ${count()} times`)} />
}The React component re-renders whenever the reactive props change — without re-running the Fict component function.
If your app uses Fict compiler macros, you can write an equivalent $state(...) style.
3. Declarative Island
import { ReactIsland } from '@fictjs/react'
import { createSignal } from '@fictjs/runtime/advanced'
import { Chart } from './react/Chart'
function Dashboard() {
const data = createSignal<number[]>([])
return (
<ReactIsland
component={Chart}
props={() => ({ data: data(), height: 300 })}
client="visible"
ssr
/>
)
}4. Lazy-Loaded Island (Resumable)
import { reactify$ } from '@fictjs/react'
export const LazyChart = reactify$({
module: import.meta.url,
export: 'Chart',
client: 'idle',
ssr: true,
})The component module is loaded only when the client strategy fires. On the server the optional component reference is used for SSR; on the client the QRL triggers a dynamic import.
Serialized props are written to data-fict-react-props on the host element, making the island fully resumable from server-rendered HTML.
If lazy module loading fails transiently, reactify$ retries with bounded exponential backoff (base 100ms, capped at 5s, max 5 failures).
5. Static Islands (Loader)
Mount React components from plain HTML without any Fict runtime involvement:
<div
data-fict-react="./components/Widget.js#Widget"
data-fict-react-client="visible"
data-fict-react-props="%7B%22title%22%3A%22Hello%22%7D"
></div>data-fict-react-props must contain URL-encoded, serialization-safe data.
For plain HTML authoring, use JSON-compatible primitives/objects/arrays.
For advanced Fict-serialized values (for example action refs), prefer server output produced by reactify$/Fict runtime instead of manually crafting attributes.
import { installReactIslands } from '@fictjs/react/loader'
const cleanup = installReactIslands({
observe: true, // Watch for dynamically added islands
defaultClient: 'idle', // Fallback client strategy
visibleRootMargin: '200px',
})
// Later: cleanup() to disconnect observer and unmount all islandsThe loader uses MutationObserver to detect new island hosts and attribute changes. Updating data-fict-react-props on a mounted host triggers a React re-render. Changing the QRL (data-fict-react) disposes the old root and mounts a fresh one.
When component module loading fails transiently, the loader also retries with the same bounded exponential backoff policy.
6. Serializable Actions
Pass callbacks from Fict to React across the serialization boundary:
import { reactAction$ } from '@fictjs/react'
// In a Fict component
<RemoteEditor
onSave={reactAction$(import.meta.url, 'handleSave')}
/>// Same module — the exported handler
export function handleSave(content: string) {
console.log('Saved:', content)
}Props matching /^on[A-Z]/ are automatically detected as action refs. For non-standard callback prop names, declare them explicitly:
const RemoteEditor = reactify$({
module: import.meta.url,
export: 'Editor',
actionProps: ['submitHandler', 'validateFn'],
})Client Strategies
Control when each island mounts on the client:
| Strategy | Behavior |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| 'load' | Mount immediately (via microtask). Default. |
| 'idle' | Mount during idle time (requestIdleCallback, falls back to setTimeout(…, 1)) |
| 'visible' | Mount when the host element enters the viewport (IntersectionObserver with configurable rootMargin, default 200px) |
| 'hover' | Mount on first mouseover or focusin on the host |
| 'event' | Mount on configured host events (event option or data-fict-react-event; defaults to click) |
| 'signal' | Mount when a provided reactive accessor (signal option) becomes true |
| 'only' | Client-only rendering — no SSR, no hydration |
When ssr is true (the default), the React subtree is rendered to HTML on the server. On the client, the island hydrates (hydrateRoot) if SSR content is present, otherwise it creates a fresh root (createRoot).
API Reference
reactify<P>(component, options?)
Wraps a React component as a Fict component. Props flow reactively from the Fict side; the React root updates when props change.
Options (ReactInteropOptions):
| Option | Type | Default | Description |
| ------------------- | -------------------------- | --------- | --------------------------------------------- |
| ssr | boolean | true | Server-side render the React subtree |
| client | ClientDirective | 'load' | Client mount strategy |
| event | string \| string[] | — | Event names for client: 'event' mounts |
| signal | boolean \| () => boolean | — | Mount gate for client: 'signal' |
| visibleRootMargin | string | '200px' | Margin for 'visible' strategy |
| identifierPrefix | string | '' | React useId prefix for multi-root pages |
| tagName | string | 'div' | Host element tag used by the island wrapper |
| actionProps | string[] | [] | Additional callback prop names to materialize |
ReactIsland<P>(props)
Declarative island component. Accepts component, props (value or getter), and all ReactInteropOptions.
reactify$<P>(options)
Creates a lazy-loadable Fict component backed by a QRL.
Additional options (ReactifyQrlOptions<P>):
| Option | Type | Description |
| ----------- | ------------------ | ------------------------------------- |
| module | string | Module URL, usually import.meta.url |
| export | string | Export name (default: 'default') |
| component | ComponentType<P> | Optional eager reference for SSR |
installReactIslands(options?)
Scans the document for [data-fict-react] hosts and mounts them. Returns a cleanup function.
Options (ReactIslandsLoaderOptions):
| Option | Type | Default | Description |
| ------------------- | ----------------- | --------------------- | ------------------------------------ |
| document | Document | document | Document to scan |
| selector | string | '[data-fict-react]' | CSS selector for island hosts |
| observe | boolean | true | Watch for dynamic additions/removals |
| defaultClient | ClientDirective | 'load' | Fallback client strategy |
| visibleRootMargin | string | '200px' | Margin for 'visible' strategy |
reactAction$(moduleId, exportName?)
Creates a serializable action ref from a module export. The ref is materialized into a callable function when the React component mounts.
reactActionFromQrl(qrl)
Creates an action ref from a raw QRL string.
fictReactPreset(options?)
Returns Vite plugins that scope the React JSX transform to a directory.
| Option | Type | Default | Description |
| -------------------------- | -------------------- | ------------------------------ | ----------------------------------------- |
| include | FilterPattern | [/src\/react\/.*\.[jt]sx?$/] | Files to transform with React JSX |
| exclude | FilterPattern | — | Files to exclude |
| react | ReactPluginOptions | — | Additional @vitejs/plugin-react options |
| optimizeReactDeps | boolean | true | Add React dedupe + optimizeDeps hints |
| reactDedupe | string[] | ['react', 'react-dom'] | Override dedupe package list |
| reactOptimizeDepsInclude | string[] | React runtime modules | Override optimizeDeps include list |
Host Attributes
When using the loader or resumable mode, the following data attributes control island behavior:
| Attribute | Mutable | Purpose |
| ------------------------------ | ------- | ------------------------------------------------------------------------------------- |
| data-fict-react | * | QRL pointing to the React component module |
| data-fict-react-props | yes | URL-encoded serialized props |
| data-fict-react-action-props | yes | URL-encoded JSON array of custom action prop names |
| data-fict-react-client | no | Client strategy (load / idle / visible / hover / event / signal / only) |
| data-fict-react-event | no | Comma-separated mount events for client="event" |
| data-fict-react-ssr | no | '1' if SSR content is present |
| data-fict-react-prefix | no | React useId identifier prefix |
| data-fict-react-host | — | Marks element as a React island host |
| data-fict-react-mounted | — | Set to '1' after the island mounts |
* Changing the QRL disposes the current root and creates a new one.
Immutable attributes (data-fict-react-client, data-fict-react-ssr, data-fict-react-prefix, data-fict-react-event) emit a warning in development if mutated at runtime. To change them, recreate the host element.
client="signal" requires a runtime signal accessor and is therefore not supported by installReactIslands static mounting.
Package Exports
@fictjs/react → Main API (reactify, ReactIsland, reactify$, reactAction$, …)
@fictjs/react/loader → installReactIslands
@fictjs/react/preset → fictReactPresetDevelopment
pnpm install
pnpm dev # Watch mode
pnpm build # Production build
pnpm test # Unit tests (vitest)
pnpm test:it # Integration tests
pnpm test:e2e # E2E tests (Playwright + Chromium)
pnpm lint # ESLint
pnpm typecheck # TypeScript validation