Skip to main content
On this pageOverview

Submodel

Overview

At some point, your app has 30 Messages, a sprawling Model, and an update function that scrolls for days. You’ve outgrown a single Model, Message, and update. It’s time to decompose into Submodels.

A Submodel is a self-contained Model, Message, update, and Commands: the same pieces you already know, just embedded inside a larger program. A parent embeds the child by reserving a field for its Model, declaring a wrapper Message that carries the child’s Message, and delegating to it in update.

You’ll reach for a Submodel for one of two reasons:

  • Encapsulation. The Submodel is a self-contained unit with its own state, keyboard handling, and accessibility wiring; the parent doesn’t need to see inside. Every stateful Foldkit UI primitive (Ui.Dialog, Ui.Menu, Ui.Listbox, etc.) is shipped this way, which is how they hand you their behavior without you having to know how they work inside.
  • Decomposition. Your own app has grown large enough that splitting feature areas (for example Settings, Dashboard, or Profile) into Submodels keeps things organized. These children aren’t strictly black boxes; they may need to read parent state or surface domain facts back to the parent.

Either way, you’ll often want multiple instances of the same Submodel, for example several accordions on a page, each entry in a form with its own internal state, or repeated cards in a wizard. The Submodel is the unit you instantiate.

The word "boundary"

Each h.submodel call creates a boundary: a runtime scope holding that embed site’s slotId and toParentMessage. When the child dispatches a Message, the runtime crosses the boundary, applying toParentMessage to lift the Message into the parent’s Message type. Nested Submodels chain boundaries; dispatch walks up through all of them to reach the top-level Message. The term appears throughout this page.

In the restaurant analogy, think of a large restaurant with multiple stations, for example a sushi bar, a grill, or a pastry counter. Each station has its own chef, its own order flow, its own plating. But the head waiter still coordinates: taking the order, routing it to the right station, and combining everything onto the table.

Compare to React

In React, components nest and communicate through props and callbacks. In Foldkit, composition is explicit: the parent embeds the child’s Model, wraps its Messages, and delegates in update. Every message that crosses the boundary is visible in the update function.

When NOT to use a Submodel

If a piece of UI is just a function of parent state with no internal Messages or update logic, write it as an ordinary render function, not a Submodel. The Submodel machinery (wrapper Messages, defineView brand, h.submodel embedding) is overhead unless the child genuinely owns its own state machine. Foldkit UI primitives are Submodels because they have keyboard handling, focus state, dismissal logic, animation lifecycles. A reusable card that takes a title and content as props isn’t a Submodel; it’s a render function.

The Child Submodel

A child Submodel has its own Model, Message, update, and Commands. For example, here’s a Settings Submodel for an app’s Settings page:

// page/settings.ts
import { Match as M, Schema as S } from 'effect'
import { Command } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

// MODEL

export const Theme = S.Literals(['Light', 'Dark', 'System'])
export type Theme = typeof Theme.Type

export const FontSize = S.Literals(['Small', 'Medium', 'Large'])
export type FontSize = typeof FontSize.Type

export const Model = S.Struct({
  theme: Theme,
  fontSize: FontSize,
  notificationsEnabled: S.Boolean,
})

export type Model = typeof Model.Type

// MESSAGE

export const ChangedTheme = m('ChangedTheme', { theme: Theme })
export const ChangedFontSize = m('ChangedFontSize', { fontSize: FontSize })
export const ToggledNotifications = m('ToggledNotifications')

export const Message = S.Union([
  ChangedTheme,
  ChangedFontSize,
  ToggledNotifications,
])
export type Message = typeof Message.Type

// UPDATE

export const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<
      readonly [Model, ReadonlyArray<Command.Command<Message>>]
    >(),
    M.tagsExhaustive({
      ChangedTheme: ({ theme }) => [evo(model, { theme: () => theme }), []],
      ChangedFontSize: ({ fontSize }) => [
        evo(model, { fontSize: () => fontSize }),
        [],
      ],
      ToggledNotifications: () => [
        evo(model, { notificationsEnabled: enabled => !enabled }),
        [],
      ],
    }),
  )

Notice that this Submodel has no awareness of its parent. It manages its own state and handles its own Messages in update. This isolation is the point: you can reason about each Submodel independently.

Embedding the Submodel

The parent has three jobs: embed the child’s Model, wrap its Messages, and delegate to its update.

Embedding the Model

The child’s Model becomes a field in the parent’s Model:

import { Schema as S } from 'effect'

import { Settings } from './page'

export const Model = S.Struct({
  username: S.String,
  settings: Settings.Model,
})

export type Model = typeof Model.Type

Never Bypass the Child’s Update

Having the child’s Model as a field doesn’t give the parent license to reach into it. Every change to the child’s state must go through the child’s update, never through evo on the child’s slice. Here’s the antipattern:

// ❌ Don't reach into the child's Model from the parent's update.
// This bypasses Settings.update, so DevTools never sees the change,
// and any invariant Settings.update was enforcing is silently violated.
ClickedResetSettings: () => [
  evo(model, {
    settings: settings => evo(settings, { theme: () => 'Light' }),
  }),
  [],
]

Instead, go through the child’s update. When the parent has its own Message that needs to change child state (for example, a click handler in the parent), the canonical form is to call a helper the child exports. The parent writes Settings.setTheme(model.settings, "Light") and never imports ChangedTheme. The child’s Message surface stays internal; the helper is the public verb the parent sees. The snippet’s Command.mapMessages and GotSettingsMessage are covered just below, in Wrapping Messages and Delegating in update:

// ✅ page/settings.ts — setTheme wraps update; ChangedTheme stays internal
export const setTheme = (model: Model, theme: Theme) =>
  update(model, ChangedTheme({ theme }))

// main.ts — the parent calls the verb and maps the child's Commands
ClickedResetSettings: () => {
  const [nextSettings, commands] = Settings.setTheme(model.settings, 'Light')
  return [
    evo(model, { settings: () => nextSettings }),
    Command.mapMessages(commands, message => GotSettingsMessage({ message })),
  ]
}

This is the same delegation pattern the stateful Foldkit UI primitives use for parent-initiated operations. Listbox.selectItem, Popover.close, and Tabs.selectTab are helpers a parent calls without ever constructing the child’s Message, so the child can restructure its Messages later without touching any consumer. Each is a thin wrapper over the child’s update and returns the same shape that update does. Settings has no OutMessage, so setTheme returns [Model, Commands]. The UI primitives surface facts, so theirs return the [Model, Commands, Option<OutMessage>] 3-tuple and emit. When the parent instead needs to conform the child to an external value without emitting, the silent counterpart is the reflect* family covered in Reflecting External State.

This only applies to parent-initiated changes. For Messages that came from the child via GotChildMessage (a toggle dispatched from the user clicking the child’s button, a Command result returning back into the child), you already have the Message; just call Child.update(model.child, message) directly. The helper pattern is for the inverse direction: parent code that needs to drive the child.

Three things break when the parent bypasses the child’s update.

First, DevTools never sees the change as a Submodel Message, so it disappears from the Submodel filter and the timeline reads wrong.

Second, any invariant the child’s update was enforcing (for example validation, derived fields, or state-machine transitions) is silently violated. The parent has no way to type-check against the child’s contract.

Third, the bypass becomes a refactor landmine: the moment the child adds a new invariant or restructures its internal state, the parent’s direct write breaks in ways the type system can’t catch.

Wrapping Messages

To the Foldkit runtime, every Message is top-level. Each Message is processed by your program’s main update function, and routing to a child Submodel is explicit: the parent wraps the child’s Message at the boundary in a Message its own update can process. Use the Got*Message prefix: GotSettingsMessage, GotProductsMessage, etc:

import { Schema as S } from 'effect'
import { m } from 'foldkit/message'

import { Settings } from './page'

export const GotSettingsMessage = m('GotSettingsMessage', {
  message: Settings.Message,
})

export const Message = S.Union([GotSettingsMessage])
export type Message = typeof Message.Type

DevTools expects this naming convention

The Foldkit DevTools use the Got*Message pattern to power the Submodel filter, which lets you scope DevTools Messages to a chosen Submodel. If your wrapper Messages don’t follow this naming convention, they won’t appear in the list of filterable Submodel Messages.

A wrapper Message carries routing, not payload. Its job is delivery: it holds the inner child Message and, when several instances of the same Submodel are embedded, the per-instance identifier (e.g. GotEntryMessage({ entryId, message })). Anything else belongs inside the child Message, where the child’s update can process it. Mixing domain payload into the wrapper smuggles parent-side logic past the child’s boundary, which is exactly the encapsulation breach this whole pattern is designed to prevent.

Delegating in update

When the parent receives a GotSettingsMessage, it unwraps the child Message, calls the child’s update, updates the child’s slice of the Model, and maps the child’s returned Commands back into the parent’s Message type:

import { Match as M } from 'effect'
import { Command } from 'foldkit'
import { evo } from 'foldkit/struct'

export const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.tagsExhaustive({
      GotSettingsMessage: ({ message }) => {
        const [nextSettings, commands] = Settings.update(
          model.settings,
          message,
        )

        const mappedCommands = Command.mapMessages(commands, message =>
          GotSettingsMessage({ message }),
        )

        return [evo(model, { settings: () => nextSettings }), mappedCommands]
      },
    }),
  )

About the Command mapping: the Submodel’s Commands produce child Messages when they complete, but the Foldkit runtime expects top-level Messages. The child can’t wrap its own Commands because it doesn’t know its parent’s Message type. So the parent uses Command.mapMessages to lift every Command in the list, wrapping each result in GotSettingsMessage. The helper preserves each Command’s name and args, so DevTools traces still show each Command’s original name.

Wiring the View with h.submodel

The Submodel exports a view defined with Submodel.defineView<Model, Message>. The function takes the child’s model and returns Html, the same shape a top-level program’s view has.

The <Model, Message> type arguments aren’t just annotations: they brand the view, attaching the child’s Message type to the value at the type level. The h.submodel call site reads that brand to type-check the embed site without you having to spell it out. A third optional type parameter, ViewInputs, threads per-render data from the parent; the next section covers it.

// page/settings.ts
import { Submodel } from 'foldkit'
import { type Html, html } from 'foldkit/html'

import {
  ChangedFontSize,
  ChangedTheme,
  type Message,
  ToggledNotifications,
} from './message'
import type { Model } from './model'

// The Submodel exports a view defined with Submodel.defineView<Model, Message>.
// The returned function takes the child's Model and produces Html. The
// <Model, Message> type arguments brand the view with its Message type so the
// parent can lift each emitted Message into its wrapper Message when it embeds
// the Submodel.
export const view = Submodel.defineView<Model, Message>((model): Html => {
  const h = html<Message>()

  return h.div(
    [h.Class('flex flex-col gap-4')],
    [
      h.h2([h.Class('text-xl font-bold')], ['Settings']),
      h.div(
        [h.Class('flex gap-2')],
        [
          h.button([h.OnClick(ChangedTheme({ theme: 'Light' }))], ['Light']),
          h.button([h.OnClick(ChangedTheme({ theme: 'Dark' }))], ['Dark']),
          h.button([h.OnClick(ChangedTheme({ theme: 'System' }))], ['System']),
        ],
      ),
      h.div(
        [h.Class('flex gap-2')],
        [
          h.button(
            [h.OnClick(ChangedFontSize({ fontSize: 'Small' }))],
            ['Small'],
          ),
          h.button(
            [h.OnClick(ChangedFontSize({ fontSize: 'Medium' }))],
            ['Medium'],
          ),
          h.button(
            [h.OnClick(ChangedFontSize({ fontSize: 'Large' }))],
            ['Large'],
          ),
        ],
      ),
      h.button(
        [h.OnClick(ToggledNotifications())],
        [
          model.notificationsEnabled
            ? 'Disable notifications'
            : 'Enable notifications',
        ],
      ),
    ],
  )
})

The parent embeds the Submodel via h.submodel, passing four things:

  • slotId: a string that uniquely identifies this embed site under the current boundary. For a single instance, a stable name like 'settings' works; for repeated instances, a per-instance value like row.id.
  • model: the child’s slice of the parent Model.
  • view: the child’s exported view, branded by Submodel.defineView so the embed site can infer the child’s Message type.
  • toParentMessage: a callback that lifts each child Message into the parent’s wrapper Message.
// main.ts (parent)
import { type Document, html } from 'foldkit/html'

import { GotSettingsMessage, type Message } from './message'
import type { Model } from './model'
import { Settings } from './page'

// The parent embeds the child via h.submodel. The slotId is unique within
// the parent's view, view is the child's exported view function, model is the
// embedded slice, and toParentMessage lifts every Message the child emits
// into the parent's GotSettingsMessage envelope. The child stays decoupled
// from this parent; the same Settings.view embeds under any parent that
// supplies a compatible wrapping.
export const view = (model: Model): Document => {
  const h = html<Message>()

  return {
    title: 'My App',
    body: h.div(
      [h.Class('min-h-screen bg-gray-50')],
      [
        h.submodel({
          slotId: 'settings',
          model: model.settings,
          view: Settings.view,
          toParentMessage: message => GotSettingsMessage({ message }),
        }),
      ],
    ),
  }
}

The same Settings.view embeds under any parent that supplies a compatible toParentMessage. The child has no static dependency on a particular parent.

Per-render View Inputs

Some Submodels need data from the parent on every render that doesn’t belong in the child’s model. A Listbox needs the array of items and a callback that renders each one. A Menu needs the items and the trigger button’s content. A collapsible panel needs the summary and the content the parent wants to show. None of this is the child’s state. It’s configuration the parent supplies fresh on every render.

For these Submodels, defineView takes a third type parameter ViewInputs. The view receives viewInputs as its second argument:

// page/collapsible.ts
import { Submodel } from 'foldkit'
import { type Html, html } from 'foldkit/html'

import { type Message, ToggledOpen } from './message'
import type { Model } from './model'

// The third type parameter to defineView is `ViewInputs`: per-render
// data the parent passes alongside the model. Here, the parent supplies
// the summary and content Html; the child supplies the open/closed
// behavior.
export type ViewInputs = Readonly<{
  summary: Html
  content: Html
}>

export const view = Submodel.defineView<Model, Message, ViewInputs>(
  (model, viewInputs): Html => {
    const h = html<Message>()

    return h.div(
      [h.Class('flex flex-col gap-2')],
      [
        h.button([h.OnClick(ToggledOpen())], [viewInputs.summary]),
        model.isOpen ? h.div([h.Class('pl-4')], [viewInputs.content]) : h.empty,
      ],
    )
  },
)

At the embed site, the parent passes the viewInputs alongside model and the other fields:

// main.ts (parent)
import { type Document, html } from 'foldkit/html'

import { GotCollapsibleMessage, type Message } from './message'
import type { Model } from './model'
import { Collapsible } from './page'

// The parent passes `viewInputs` alongside model/view/toParentMessage.
// `summary` and `content` are Html the parent builds; the child slots
// them into its open/closed widget. The child has no idea what the
// summary or content actually are. Only that they exist.
export const view = (model: Model): Document => {
  const h = html<Message>()

  return {
    title: 'My App',
    body: h.div(
      [],
      [
        h.submodel({
          slotId: 'about-section',
          model: model.about,
          view: Collapsible.view,
          viewInputs: {
            summary: h.span([], ['About this app']),
            content: h.p(
              [],
              ['Built with Foldkit. The architecture is a single loop.'],
            ),
          },
          toParentMessage: message => GotCollapsibleMessage({ message }),
        }),
      ],
    ),
  }
}

The split between model and viewInputs is load-bearing. model is the child’s internal state, owned by the child and mutated only through the child’s update. viewInputs is per-render configuration, owned by the parent and rebuilt fresh each render. Putting per-render config in the model would force the parent to write update handlers that store its own configuration; putting state in the viewInputs would lose it across renders.

A common pattern is to put a slot callback (often called toView) in viewInputs so the child hands the parent attribute bundles and lets the parent shape the markup. Functions at the top level of viewInputs get auto-wrapped to execute in the parent’s boundary, so any handlers the parent builds inside them (e.g. h.OnClick(ParentMessage())) dispatch through the parent’s wrapping chain, not the child’s. See the childAttributes section below for the complementary mechanism: how the child publishes attribute bundles that route back through its own boundary.

Keep slot callbacks at the top level

Functions nested inside an object or array inside viewInputs (e.g. viewInputs: { config: { onSubmit } }) throw at view-build time with a path-based error like viewInputs.config.onSubmit. The auto-wrap only descends one level, so a nested function would otherwise dispatch through the child’s boundary instead of the parent’s. The check is runtime-only, so a misuse compiles cleanly and surfaces the first time the boundary renders. Lift slot callbacks to the top level of viewInputs.

Boundary Id and Model Identity

The slotId you pass to h.submodel is DOM-slot identity, not model identity. Each h.submodel call under the same parent boundary must use a distinct slotId, even when two call sites embed the same model.

If you render the same Submodel in two slots (desktop + mobile, master + detail, mirror layouts), give each slot its own id like 'desktop-sidebar' and 'mobile-sidebar', not just model.id. For lists, the per-item id (row.id) is the right choice because each row IS a different slot.

Defaulting to model.id works for the common case of one model rendered in one slot, but silently collides as soon as the model appears twice. The runtime catches the collision with a duplicate-slotId throw at view-build time. The throw is the convention’s safety net; the prevention is naming slot ids by slot from the start.

Multiple Instances

A parent often embeds several instances of the same Submodel, for example a list of form entries, an array of accordions, or repeated cards on a dashboard. There are two shapes.

For a fixed number of instances, embed each as a separate field on the parent Model with its own slotId. h.submodel({ slotId: 'profile', ... }) and h.submodel({ slotId: 'preferences', ... }) are two unrelated boundaries, each with its own wrap.

For a dynamic number, hold the instances in an array on the parent Model, iterate it in the view, and route updates back through a wrapper Message that carries a per-instance identifier:

import { Array, Option } from 'effect'
import { Command } from 'foldkit'
import { type Html, html } from 'foldkit/html'
import { evo } from 'foldkit/struct'

import { Applicant } from './applicant'
import { GotApplicantMessage, type Message } from './message'
import type { Model } from './model'

// View: iterate the array of children and embed each as its own
// `h.submodel`. The `id` is the stable per-instance identifier. The
// wrapper Message carries `entryId` so update can route back.
export const view = (model: Model): Html => {
  const h = html<Message>()

  return h.div(
    [h.Class('flex flex-col gap-4')],
    Array.map(model.applicants, applicant =>
      h.submodel({
        slotId: applicant.id,
        model: applicant.entry,
        view: Applicant.view,
        toParentMessage: message =>
          GotApplicantMessage({ entryId: applicant.id, message }),
      }),
    ),
  )
}

// Update: route the wrapper Message by `entryId` to the right slice.
// Find the matching applicant, delegate to the child's update, and
// re-wrap any Commands the child returned with the same `entryId`.
GotApplicantMessage: ({ entryId, message }) =>
  Option.match(
    Array.findFirst(model.applicants, applicant => applicant.id === entryId),
    {
      onNone: () => [model, []],
      onSome: matchedApplicant => {
        const [nextEntry, commands] = Applicant.update(
          matchedApplicant.entry,
          message,
        )
        return [
          evo(model, {
            applicants: Array.map(applicant =>
              applicant.id === entryId
                ? evo(applicant, { entry: () => nextEntry })
                : applicant,
            ),
          }),
          Command.mapMessages(commands, childMessage =>
            GotApplicantMessage({ entryId, message: childMessage }),
          ),
        ]
      },
    },
  )

The slotId on each h.submodel is the per-instance identifier the runtime uses for boundary identity. The same identifier travels with the wrapper Message as entryId so the parent’s update can find the matching slice and delegate to Applicant.update. See the job-application example for a working version: per-entry education and work-history Submodels, each embedded with its own entryId.

Memoization Across Submodel Boundaries

An h.submodel call re-runs the child’s view on every parent render. For a short list of Submodels this is cheap; for a long list (hundreds of rows) or expensive child views, memoize the embed site with createKeyedLazy from foldkit/html. The keyed lazy compares its deps tuple by === and reuses the cached VNode when nothing changed.

Why this works across the Submodel boundary: h.submodel is designed so the per-render-fresh toParentMessage closure stays out of the cached VNode. The boundary’s wrap is stored in a runtime registry keyed by id, not captured by the VNode itself, so a cache hit preserves the wrap from the previous render. Snabbdom’s destroy hook deregisters the wrap when the DOM node is actually removed, so memoization across boundaries doesn’t leak.

Default: don’t memoize. Reach for createKeyedLazy when a profile shows the parent re-renders are doing measurable work.

Reading Parent State

Decomposition Submodels (Settings, Dashboard, Profile) often share state with their siblings: the current user, the active locale, the session token. Forcing every such child to be fully encapsulated pushes you to duplicate that state into the child Model and keep both copies in sync, which is worse on every axis. Foldkit gives you two precise seams for parent state to reach the child instead:

  • When a child view needs to render state that lives in the parent Model, thread it through viewInputs on h.submodel.
  • When a child update needs context from the parent, add a third context argument to the child’s update.

Both are typed contracts the child declares and the parent honors.

Passing Parent State to a Child Submodel’s view

Slice the parent state out of the parent Model and pass it through viewInputs on h.submodel. Because viewInputs is rebuilt every render, the child always sees the current value without storing a copy:

import { Submodel } from 'foldkit'
import { type Html, html } from 'foldkit/html'

import type { User } from '../user'
import type { Message } from './message'
import type { Model } from './model'

// The child declares the parent state it needs via the third type
// parameter on `Submodel.defineView`. The view receives it as
// `viewInputs` alongside `model`.
type ViewInputs = Readonly<{
  currentUser: User
}>

export const view = Submodel.defineView<Model, Message, ViewInputs>(
  (model, { currentUser }): Html => {
    const h = html<Message>()

    return h.div(
      [],
      [
        h.h2([], [`Settings for ${currentUser.name}`]),
        // ...rest of the Settings UI driven by `model`
      ],
    )
  },
)

// Inside the parent's view, slice currentUser out of the parent Model
// and pass it through viewInputs. Rebuilt every render, so the child always
// sees the current value:
h.submodel({
  slotId: 'settings',
  model: model.settings,
  view: Settings.view,
  viewInputs: {
    currentUser: model.currentUser,
  },
  toParentMessage: message => GotSettingsMessage({ message }),
})

The mechanism is the same one Per-render View Inputs describes; parent state is just one of the things viewInputs can carry.

Providing Parent State to a Child Submodel’s update

The child’s update signature grows a third argument: (model, message, context) => result. The child declares a Context type alongside its other types; the parent assembles the context inline when delegating in its own update handler:

import { Match as M } from 'effect'
import type { Command } from 'foldkit'
import { evo } from 'foldkit/struct'

import type { User } from '../user'
import { type Message, PersistSettings } from './message'
import type { Model } from './model'

// The Context shape is declared by the child. The parent assembles it
// inline when delegating in its own update handler.
type Context = Readonly<{
  currentUser: User
}>

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]

// The child's update grows a third `context` argument carrying the
// parent state it needs.
export const update = (
  model: Model,
  message: Message,
  context: Context,
): UpdateReturn =>
  M.value(message).pipe(
    M.withReturnType<UpdateReturn>(),
    M.tagsExhaustive({
      ChangedTheme: ({ theme }) => [
        evo(model, { theme: () => theme }),
        [PersistSettings({ userId: context.currentUser.id, theme })],
      ],
      // ...other arms
    }),
  )

// Inside the parent's update handler, assemble the context from the
// parent Model and pass it through to the child's update:
GotSettingsMessage: ({ message }) => {
  const [nextSettings, commands] = Settings.update(model.settings, message, {
    currentUser: model.currentUser,
  })
  // ...usual wrapping of `commands`
}

The update stays pure. Same (model, message, context) always produces the same result; no hidden state, no time-dependent behavior. The child reads context.currentUser at the moment the message is being processed, and because the parent assembles the context fresh on every dispatch, the next call automatically sees any parent changes. Single source of truth, no sync obligation.

A context argument gives the child the current value when update runs. It does not notify the child when that value changes. If the child needs to respond to currentUser changing (for example to clear caches or reset a form), the canonical move is for the parent to dispatch a child Message through GotChildMessage carrying the new value. Context-arg is for reading current parent state inside an update tick, not for observing parent state over time.

Surfacing Facts to the Parent

So far the child only sends its own Messages back through the parent’s wrapper. That covers internal state changes, but it doesn’t tell the parent that something the parent cares about happened: a date was committed, a tab was selected, a menu item was picked. For that, the child’s update returns a third element: Option<OutMessage> (Effect’s Option type for representing a value that may or may not be present). The parent pattern-matches the third element inside GotChildMessage and lifts the fact into a domain Message of its own.

Your login Submodel has authenticated the user. Now what? The child can’t transition the root Model to a logged-in state because it only knows about its own Model. And it shouldn’t know about the root Model. That would break the encapsulation that makes Submodels useful in the first place.

The OutMessage shape solves this. The child emits a semantic event: "login succeeded, here’s the session." The parent decides what to do with it. The child describes what happened; the parent decides the consequences.

Compare to React

In React, you’d pass an onLoginSuccess callback as a prop. This works but couples the child to the parent’s interface. In Foldkit, OutMessage keeps the boundary clean: the child emits facts, the parent interprets them.

Defining OutMessages

OutMessages live alongside the child’s Message and follow the same naming conventions: past-tense facts describing what happened. SucceededLogin, not TransitionToLoggedIn. RequestedLogout, not DoLogout. The child doesn’t know or care what the parent does with the information.

import { Schema as S } from 'effect'
import { m } from 'foldkit/message'

// MESSAGE

export const SubmittedLoginForm = m('SubmittedLoginForm')
export const SucceededAuthenticate = m('SucceededAuthenticate', {
  sessionId: S.String,
})

export const Message = S.Union([SubmittedLoginForm, SucceededAuthenticate])
export type Message = typeof Message.Type

// OUT MESSAGE

export const SucceededLogin = m('SucceededLogin', {
  sessionId: S.String,
})

export const OutMessage = S.Union([SucceededLogin])
export type OutMessage = typeof OutMessage.Type

Emitting from the Child

The child’s update function returns a 3-tuple instead of the usual 2-tuple: Model, Commands, and an Option<OutMessage>. Most Messages return Option.none(). Only the significant "I need to tell the parent something" moments return Option.some(...):

import { Match as M, Option } from 'effect'
import { Command } from 'foldkit'

export const update = (
  model: Model,
  message: Message,
): [
  Model,
  ReadonlyArray<Command.Command<Message>>,
  Option.Option<OutMessage>,
] =>
  M.value(message).pipe(
    M.tagsExhaustive({
      SubmittedLoginForm: () => [
        model,
        [Authenticate(model.email, model.password)],
        Option.none(),
      ],
      SucceededAuthenticate: ({ sessionId }) => [
        model,
        [],
        Option.some(SucceededLogin({ sessionId })),
      ],
    }),
  )

The Option makes the boundary explicit. SubmittedLoginForm fires a Command and returns Option.none(): nothing for the parent to act on yet. But when login succeeds, the SucceededAuthenticate arm emits Option.some(SucceededLogin({ sessionId })), the signal the parent needs.

Handling in the Parent

The parent uses Option.match on the OutMessage. onNone means the child handled it internally: just update the child’s slice of the Model. onSome means the child is surfacing something the parent needs to act on:

import { Match as M, Option } from 'effect'
import { Command } from 'foldkit'
import { evo } from 'foldkit/struct'

export const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.tagsExhaustive({
      GotLoginMessage: ({ message }) => {
        const [nextLogin, commands, maybeOutMessage] = Login.update(
          model.login,
          message,
        )

        const mappedCommands = Command.mapMessages(commands, message =>
          GotLoginMessage({ message }),
        )

        return Option.match(maybeOutMessage, {
          onNone: () => [
            evo(model, { login: () => nextLogin }),
            mappedCommands,
          ],
          onSome: outMessage =>
            M.value(outMessage).pipe(
              M.tagsExhaustive({
                SucceededLogin: ({ sessionId }) => [
                  LoggedIn({ sessionId }),
                  [...mappedCommands, SaveSession(sessionId)],
                ],
              }),
            ),
        })
      },
    }),
  )

This is where the power of the boundary shows. When SucceededLogin arrives, the parent can do things the child has no knowledge of: transition to a completely different Model state, save the session, redirect the URL. The child stays focused on its domain; the parent handles cross-cutting concerns.

See the Auth example for a complete implementation: a login module emits SucceededLogin when authentication completes, and the parent transitions to the logged-in state, saves the session, and updates the URL, all triggered by a single OutMessage.

Reflecting External State

OutMessage is outbound: the user interacts with the child, the child changes, and it surfaces a fact so the parent can act. The child is the source. reflect* is the inbound direction. Something outside the child changes (a URL or route, a server push, restored storage, parent state, or a sibling Submodel), and the parent conforms the child to that external value. The external thing is the source.

A reflect* helper returns Model directly, not the [Model, Commands, Option<OutMessage>] tuple the choice-based setters return. The narrower return type makes the contract visible at the call site: a reflect* write cannot emit. It is silent on purpose. The external value is already the source of truth, so emitting an OutMessage would echo it back to whatever just set it and risk a write loop.

The helpers are Function.dual, so they read point-free inside an evo callback. The parent reflects a URL filter onto a Listbox from the handler that processes the route change:

ChangedUrl: ({ route }) => [
  evo(model, {
    // The URL owns the filter, so reflect it onto the Listbox. reflectSelectedItem
    // returns Model (point-free in evo) and emits nothing, so it can't echo the
    // route back and loop.
    colorFilter: ColorListbox.reflectSelectedItem(route.maybeColor),
  }),
  [],
]

The child never calls its own reflect*. It is the sanctioned way for the owner to write into the child, the complement to the never-bypass rule: the parent conforms the child to an external value through a helper instead of reaching into the child’s slice with evo. The child’s own user interactions still go through its update and emit its OutMessage along the choice path.

"Outside" is relative to the child’s boundary, not the app’s. A sibling field changing is outside the child it feeds. A start-date field that updates an end-date picker’s minimum is the canonical case: the parent handles the start-date Message and reflects the new minimum onto the end-date Submodel, which had no part in the change. The Calendar config setters (reflectMinDate, reflectMaxDate, reflectDisabledDates, reflectDisabledDaysOfWeek) are reflect helpers for exactly this.

Across the stateful Foldkit UI primitives the choice-based setters keep domain verbs (selectItem, select, selectTab, selectDate, setChecked, toggle), and they emit. The reflect* family is the uniform name for the silent inbound setter: reflectSelectedItem and reflectSelectedItems (Listbox, Combobox), reflectSelectedValue (RadioGroup), reflectSelectedTab (Tabs), reflectSelectedDate (Calendar, DatePicker), reflectChecked (Checkbox, Switch), reflectOpenState (Disclosure), reflectValue and reflectRange (Slider). It is a framework convention any Submodel can adopt; the Foldkit UI primitives are the canonical adopters.

childAttributes

Some Submodels (Disclosure, Tooltip, Dialog, Popover, the selection family) hand the consumer attribute bundles rather than rendering their own DOM. The consumer spreads those attributes onto their own elements, deciding markup and styling, while the Submodel keeps owning the wiring.

The wiring problem this creates: an attribute like h.OnClick(Toggled()) is built inside the Submodel’s view (the Submodel’s own boundary) but ends up on an element inside the consumer’s view (the parent’s boundary). The dispatch needs to route through the Submodel’s toParentMessage, not the parent’s. childAttributes solves this by branding each attribute with the Submodel’s dispatcher at publish time, so element constructors use the right one regardless of where the attribute is spread.

The Problem

A Submodel’s view builds attributes like h.OnClick(Toggled()), where Toggled is the Submodel’s own Message. When the click fires, the dispatch must route through the Submodel’s toParentMessage wrap so the parent receives GotChildMessage({ message: Toggled() }) and delegates back to the child’s update.

But the Submodel doesn’t render its own DOM. It hands attributes to a consumer’s toView slot, and the consumer composes them into their own elements. The consumer’s slot runs in the parent’s boundary, not the child’s. If the OnClick attribute were processed at the moment the consumer spread it onto a button, the click would dispatch through the parent’s boundary, bypassing the Submodel’s wrap entirely. The parent would receive a raw Toggled() it doesn’t know how to handle.

How It Works

childAttributes snapshots the Submodel’s dispatcher at the moment of publishing. Each attribute in the returned array carries that captured dispatcher with it. When the consumer’s element constructor (h.button, h.input, etc.) sees a branded ChildAttribute, it uses the carried dispatcher instead of the current one. The handler ends up wired to the Submodel’s boundary even though the element lives in the parent’s view.

In code, the Submodel’s view publishes branded attribute groups:

import { Submodel } from 'foldkit'
import {
  type ChildAttribute,
  type Html,
  childAttributes,
  html,
} from 'foldkit/html'

import { type Message, Toggled } from './message'
import { type Model, buttonId, panelId } from './model'

type ViewInputs = Readonly<{
  toView: (attributes: {
    readonly button: ReadonlyArray<ChildAttribute>
    readonly panel: ReadonlyArray<ChildAttribute>
  }) => Html
}>

// Inside the Submodel's view, running in the child's boundary. Each
// attribute group is wrapped in `childAttributes` so the child's
// dispatcher is captured at publish time. The consumer can spread
// these onto whatever elements they want without losing the wiring
// back through the Submodel's `toParentMessage`.
export const view = Submodel.defineView<Model, Message, ViewInputs>(
  (model, viewInputs) => {
    const h = html<Message>()

    return viewInputs.toView({
      button: childAttributes([
        h.OnClick(Toggled()),
        h.AriaExpanded(model.isOpen),
        h.Id(buttonId(model.id)),
      ]),
      panel: childAttributes([h.Id(panelId(model.id))]),
    })
  },
)

And the consumer’s toView callback, running in the parent’s boundary, threads those groups onto its own elements:

import { html, keyed } from 'foldkit/html'

import { Disclosure } from './disclosure'
import { GotDisclosureMessage, type Message } from './message'
import type { Model } from './model'

export const view = (model: Model) => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'disclosure',
    model: model.disclosure,
    view: Disclosure.view,
    // Each attribute group published by the child is spread onto the
    // consumer's own element. Click handlers from the child still route
    // through the child's dispatcher because the branding rides along
    // on each attribute.
    viewInputs: {
      toView: attributes =>
        h.div(
          [],
          [
            h.button(
              [...attributes.button, h.Class('px-3 py-2 rounded')],
              ['Toggle'],
            ),
            keyed('div')(
              model.disclosure.isOpen ? 'open' : 'closed',
              model.disclosure.isOpen
                ? h.div(
                    [...attributes.panel, h.Class('mt-2 p-4 bg-gray-50')],
                    ['Panel content'],
                  )
                : h.empty,
            ),
          ],
        ),
    },
    toParentMessage: message => GotDisclosureMessage({ message }),
  })
}

When the button is clicked, the OnClick attribute’s branded dispatcher routes the Toggled() message through the Submodel’s toParentMessage wrap, producing GotDisclosureMessage({ message: Toggled() }) for the parent. The consumer’s own h.Class attribute is untouched: it’s a styling attribute with no message wiring.

When to Reach For It

If you’re consuming a Foldkit UI primitive, you don’t call childAttributes yourself. The primitive’s view publishes branded attributes; you just spread them.

If you’re authoring your own Submodel and publishing attribute bundles to a consumer’s slot callback, every published attribute group must be wrapped in childAttributes. Forgetting this is a quiet bug: handlers can route through the parent’s boundary and the Submodel’s update will never see its own events. Read the published Submodels in packages/foldkit/src/ui/ for the canonical pattern.

Render helpers don’t need this

Stateless render helpers like Ui.Button and Ui.Input don’t publish via childAttributes. They’re not Submodels; their onClick or onInput values flow into element constructors in the consumer’s own boundary, which is correct. The boundary wiring only matters when there’s a Submodel boundary to wire through.

Testing Submodels

A Submodel tests the same way as a top-level program. A two-argument child update is a pure function from (model, message) to [Model, Commands] or [Model, Commands, Option<OutMessage>], so it slots straight into Story.story. The child’s view is a pure function too; assert against the rendered VNode through Scene.scene.

For Submodels that emit OutMessages, assert the third tuple element via Story.expectOutMessage. For Submodels with a context-arg update, close the context over the two-argument function you pass to Story.story: (model, message) => Settings.update(model, message, { currentUser }). Each message step carries only a Message, so the context stays fixed for the story run.

The counters example has a Story test for the parent’s wrapper-Message routing (GotCounterMessage delivers to the right row by id, unknown ids are a no-op) and a Scene test for the rendered list. See the Testing page for the full Story and Scene reference.

Debugging Submodels in DevTools

The Got*Message wrapper convention powers the Submodel filter in Foldkit DevTools. Pick a Submodel from the filter dropdown and the Message timeline scopes to dispatches that crossed that Submodel’s boundary. Model diffs show only the child’s slice.

If your wrapper Messages don’t follow the Got* naming, the filter can’t discover them; the Messages still flow correctly but they won’t appear in the Submodel dropdown. The warning callout under Wrapping Messages is the same rule from the DevTools side.

When a Submodel emits an OutMessage, the parent’s GotChildMessage handler shows both the wrapper and any domain Message the parent dispatched in response. The full causal chain is visible in the timeline.

Common Pitfalls

Issues new Submodel users hit, and where to read about the fix:

  • Duplicate slotId thrown at view-build time. Two h.submodel calls under the same parent share a slotId. See Boundary Id and Model Identity.
  • Child events not reaching the parent’s update. The wrapper Message isn’t named Got*Message, or the wrapper variant hasn’t been added to the parent’s update. See Wrapping Messages and Delegating in update.
  • Child’s view sees stale parent state. Parent state was copied into the child Model and forgotten on update. Thread it through viewInputs (rebuilt every render) instead. See Passing Parent State to a Child Submodel’s view.
  • Handlers dispatched from a slot callback fire in the wrong boundary. The Submodel published an attribute group without wrapping it in childAttributes. See childAttributes.
  • View-build error like viewInputs.config.onSubmit. A slot callback was nested inside an object or array in viewInputs. Lift it to the top level. See the warning callout under Per-render View Inputs.
  • Long list of Submodels feels slow. Default is to re-render each row every parent render. See Memoization Across Submodel Boundaries.

API Reference

h.submodel

h.submodel(config: SubmodelConfig<View>): Html

Embeds a child Submodel under the current boundary. Creates a runtime boundary holding the embed site’s slotId and toParentMessage, dispatches Messages from inside the child through the wrap chain to the parent, and deregisters the boundary when the DOM node is destroyed. See Wiring the View with h.submodel for usage; see Boundary Id and Model Identity for slotId semantics.

SubmodelConfig

The configuration record passed to h.submodel.

NameTypeDefaultDescription
slotIdstring-DOM-slot identity for this embed site under the current boundary. Must be distinct from every other h.submodel slotId under the same parent boundary. For lists, use a per-item id (row.id); for fixed slots, name by position.
modelView extends SubmodelView<infer Model, ...> ? Model : never-The child Submodel’s slice of the parent Model. Type is inferred from the branded view.
viewSubmodelView<Model, Message, ViewInputs?>-The child’s exported view, branded via Submodel.defineView so the embed site can infer the child’s Message type.
viewInputsViewInputs | undefined-Optional per-render data threaded into the view’s second argument. Top-level functions are auto-wrapped to execute in the parent’s boundary; nested functions throw at view-build time.
toParentMessage(message: ChildMessage) => ParentMessage-Lifts each child Message into the parent’s wrapper Message type, typically a closure over the Got*Message constructor.

Submodel.defineView

Submodel.defineView<Model, Message, ViewInputs = void>(fn): SubmodelView<Model, Message, ViewInputs>

Brands a view function with its Message type so h.submodel can type-check the embed site without a per-call type argument. The <Model, Message> parameters are required at the definition site; ViewInputs is optional and, when supplied, makes the view take a second viewInputs argument. Also exported as defineView from foldkit/html.

Submodel.View

Submodel.View<Model, Message, ViewInputs = void> = (model, viewInputs) => Html

The branded view type produced by Submodel.defineView. Carries the child’s Message type at the type level. Consumers don’t usually annotate values with this type directly; the brand and Parameters<View> carry the inference at the embed site. Also exported as SubmodelView from foldkit/html.

childAttributes

childAttributes<Attribute>(attributes: ReadonlyArray<Attribute>): ReadonlyArray<ChildAttribute>

Snapshots the Submodel’s dispatcher at publish time and brands each attribute so handlers route through the Submodel’s boundary when later spread into the consumer’s elements. Called inside a Submodel that publishes attribute bundles to a consumer’s toView slot. See childAttributes for the full mechanism.

ChildAttribute

ChildAttribute is the branded attribute type returned by childAttributes. Element constructors (h.button, h.input, etc.) accept ChildAttribute alongside ordinary Attribute<Message> values, using the carried dispatcher when present.

With Model, Messages, update, view, Commands, and Submodels in place, you have the full vocabulary for describing a Foldkit app. The next page covers the Runtime: the engine that executes Commands, runs Subscriptions, manages Mount and ManagedResource lifecycles, and routes Messages back into update.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson