foldkit-react
v0.4.1
Published
Run React components inside a Foldkit view and Foldkit programs inside React, with Messages and Ports as the only boundary.
Maintainers
Readme
foldkit-react
Use React components inside a Foldkit view, and Foldkit programs inside a React app. Each side keeps its own renderer and its own state; they meet at a single DOM element, and only Messages and Ports cross it.
React inside Foldkit Foldkit inside React
Model ─▶ view ─▶ ReactComponent.view <Counter step={2} onCount={…} />
│ props (a DOM prop) │ props ─▶ inbound Ports
▼ ▼
<foldkit-react-host> <div> (React owns)
│ React root │ Runtime.embed
▼ ▼
React component Foldkit program
│ event │ outbound Ports
▼ ▼
Message ─▶ update callback propsWho owns what
- React inside Foldkit. The Foldkit Model owns every value it passes as a
prop. React owns the component's internal state (
useState, focus, a library's selection), which survives Foldkit renders. A React event never changes the Model directly: you map it to a Message andupdatedecides. - Foldkit inside React. The Foldkit program owns its Model. React props reach it only through the program's inbound Ports, and it reports back only through outbound Ports. There is no access to its Model from React.
Neither renderer ever touches the other's DOM subtree, so there is no second reconciler and no shared ownership to reason about.
Install
pnpm add foldkit-react foldkit effect react react-domReact 19 is required. examples/react runs both
directions, plus codegen, as a pinned transcript.
React inside Foldkit
import { Schema } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { ReactComponent } from 'foldkit-react'
import { createElement } from 'react'
const Button = (props: { readonly label: string; readonly onPress?: () => void }) =>
createElement('button', { onClick: props.onPress }, props.label)
const ReactButton = ReactComponent.define(Button, { events: ['onPress'] })
const Model = Schema.Struct({ count: Schema.Number })
type Model = typeof Model.Type
const Message = defineMessageUnion({ Incremented: {} })
type Message = typeof Message.Type
const update = (model: Model, _message: Message) => ({
model: { count: model.count + 1 },
})
const view = (model: Model, h: HtmlBuilder<Message>): Html =>
ReactButton.view({
props: { label: `Count: ${model.count}` },
messages: { onPress: () => Message.Incremented() },
}, h)Use this view and update in a Foldkit program initialized with { count: 0 }.
The button is a real React component; its callback becomes Incremented, and
the next Model supplies its next label. Replace Button with a library widget
using the same event mapping. Non-event function props remain ordinary props.
What happens:
definedoes no rendering. It records the component and which props are events. Events must be callbacks returningvoid; a callback that returns a value (shouldClose: () => boolean) is a normal prop.viewreturns ordinary FoldkitHtml: a<foldkit-react-host>element whoseinputDOM property carries the props. Foldkit diffs that property like any other, so a new Model re-renders the same React root. It does not remount.messagesmaps each event's arguments to a Message. The Message reachesupdatethrough Foldkit's normal event path, so inside a Submodel it is lifted bytoParentMessagelike a click would be. An event without a mapper is not passed to the component.- The Message type comes from
h, so a mapper returning the wrong Message is a type error.
Suspense and errors
Each island is its own React root, so it has its own Suspense boundary: lazy,
use(promise), and libraries that suspend all work, showing
suspenseFallback (default: nothing) meanwhile.
The following integration fragment assumes your Editor, Spinner, and
EditorCrashed Message; define ReactEditor with ReactComponent.define(Editor).
ReactEditor.view(
{
props: { document: model.document },
suspenseFallback: createElement(Spinner),
errorBoundary: {
fallback: () => createElement('p', null, 'The editor crashed.'),
toMessage: error => EditorCrashed({ reason: String(error) }),
},
},
h,
)Without errorBoundary, a render error is uncaught at the island's React root,
which React handles as in any app: it removes that root's tree and reports the
error. With it, the island shows fallback and, if given,
toMessage dispatches a Message so the Model can record the crash. The
fallback stays until the island is removed.
Waiting on Model data: readAsyncData
Loading belongs to Foldkit: a Command fetches, update stores an AsyncData in
the Model. An island can suspend on that value instead of branching on its tag:
const Profile = ({ user }: { readonly user: AsyncData.AsyncData<User, string> }) => {
const { data, isRefreshing } = readAsyncData(user)
return createElement('p', null, isRefreshing ? `${data.name} (refreshing)` : data.name)
}
ReactProfile.view(
{
props: { user: model.user },
suspenseFallback: createElement(Spinner),
errorBoundary: {
fallback: error => (error instanceof AsyncDataFailure ? `Not found: ${error.error}` : 'Crashed'),
},
},
h,
)Idle | Loading -> suspends: the island's suspenseFallback
Failure(error) -> throws AsyncDataFailure: the island's errorBoundary
Success(data) -> { data }
Refreshing(data) -> { data, isRefreshing: true } no suspension
Stale(error, data) -> { data, maybeStaleError: Some(error) }readAsyncData starts nothing and caches nothing. The suspended component
retries when the next value reaches it as props or React state, which is how
island props and outbound Port values arrive; a value read from anywhere else
would never retry.
The host element
hostAttributes go on <foldkit-react-host>, which Foldkit owns: keys for
keyed lists, classes, ARIA labels. The host is display: inline by default, as
every unknown element is; style it if the component needs a block.
ReactCounter.view({ props: { id: row.id }, hostAttributes: [h.Key(row.id), h.Class('row')] }, h)With Foldkit DevTools time travel, jumping to a past Model re-renders the same React root with that Model's props; React's internal state is not rewound, since Foldkit does not own it. Island events while paused reach no Model, like any other handler in the replayed view, and resuming restores the live props.
An island removed by Foldkit unmounts its React tree, running effect cleanups. A keyed move keeps it mounted, because the host defers unmounting until after the move completes.
Foldkit inside React
import { FoldkitComponent } from 'foldkit-react'
const Counter = FoldkitComponent.define({
// Runs once per runtime. Props here are initial configuration only.
make: (container, props: CounterProps) => makeCounter(container, props.initialCount),
// Re-sent whenever the selected value changes (Object.is).
inbound: { stepChanged: props => props.step },
// Called with the latest props, so a new onCountChange needs no resubscribe.
outbound: { countChanged: (count, props) => props.onCountChange?.(count) },
})
// <Counter initialCount={10} step={2} onCountChange={setCount} />makeCounter returns a program from Runtime.makeElement with ports. Port
names and value types are checked against it: bindings send and receive each
Port's Encoded side, the same values EmbedHandle.ports uses.
The component renders a <div> (it accepts className and style) and embeds
the program in a child element React never renders. On unmount it disposes the
runtime, stopping its Subscriptions, Managed Resources, and in-flight
Commands.
- Strict Mode mounts, disposes, and mounts again; only the second runtime keeps running.
- Initial values arrive in order. Inbound values sent before the program's
Subscriptions start are buffered, and outbound values emitted by
initCommands are delivered after the listeners are attached. - Invalid inbound values fail the Port's Schema, are logged, and never reach the app. The same invalid value is not re-sent on every render.
Custom equality and restarts
FoldkitComponent.define({
make: (container, props: DashboardProps) => makeDashboard(container, props.userId),
inbound: { filtersChanged: { select: props => props.filters, equals: shallowEqual } },
// A different user is a different program, not a new Port value.
restartKey: props => props.userId,
})When restartKey changes (compared with Object.is), the runtime is disposed
and a fresh one is made with the current props, which also receives every
inbound value.
useFoldkitElement
The hook under FoldkitComponent, for components that need the Port handles
themselves:
const { ref, handle } = useFoldkitElement({
make: container => makeCounter(container, 0),
})
useEffect(() => {
handle?.ports.stepChanged.send(step)
}, [handle, step])
return createElement('div', { ref })handle is undefined before the first effect and after unmount. onEmbed
runs synchronously after embed, the place to subscribe to an outbound Port
without missing emissions from init. Give the ref'd element no React
children.
Limits
- No Foldkit children inside a React island: pass React children as
props.children, and place Foldkit content beside the island. - Foldkit Commands are not React Suspense. An outer
<Suspense>does not wait for an embedded program; the program shows its own loading states from its Model. - A server render emits an empty
<foldkit-react-host>; the React component renders on the client. - The event mapper's argument types come from the last overload of an overloaded callback prop.
