React / foldkit-react
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.