Skip to content

fictjs/react

Repository files navigation

@fictjs/react

CI npm license

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

For the Vite preset (optional):

pnpm add -D @vitejs/plugin-react vite

Requirements

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

The 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. In browser runtime, dynamic module URLs are same-origin restricted by default. Use setReactModuleUrlPolicy(...) to explicitly allow additional trusted sources.

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'],
})

7. Module URL Security Policy

import { setReactModuleUrlPolicy } from '@fictjs/react'

setReactModuleUrlPolicy((resolvedUrl, kind) => {
  if (kind === 'action') {
    return resolvedUrl.startsWith('https://cdn.example.com/')
  }

  return resolvedUrl.startsWith('/') || resolvedUrl.startsWith('https://cdn.example.com/')
})

Dynamic component/action imports are validated before loading. Keep this policy strict unless you fully trust the source.

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.

setReactModuleUrlPolicy(policy)

Sets a global predicate for dynamic module URL validation.

  • policy(resolvedUrl, kind) => boolean
  • kind: 'component' or 'action'
  • Pass null to restore the default policy

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 → fictReactPreset

Development

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

License

MIT

About

React interoperability layer for Fict

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors