React / foldkit-react

React inside Foldkit

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

  • define does no rendering. It records the component and which props are events. Events must be callbacks returning void; a callback that returns a value (shouldClose: () => boolean) is a normal prop.
  • view returns ordinary Foldkit Html: a <foldkit-react-host> element whose input DOM 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.
  • messages maps each event's arguments to a Message. The Message reaches update through Foldkit's normal event path, so inside a Submodel it is lifted by toParentMessage like 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).

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

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

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