Skip to main content
On this pageWhen to Create a Submodel

Submodel

When to Create a Submodel

Encapsulation and Decomposition

Use a Submodel when part of the application owns a state machine, not merely a section of markup. A Submodel has its own Model, Message union, and update function. It may also own Commands, Subscriptions, OutMessages, or a view. Its parent stores the child Model, routes child Messages, and decides what to do with facts that cross the boundary.

Two needs commonly create that boundary:

  • Encapsulation: A reusable component owns interaction state, keyboard behavior, focus, or accessibility wiring that consumers should not manage. Stateful Foldkit UI Submodels such as Dialog, Menu, and Listbox use this shape.

  • Decomposition: A feature area such as Settings or Profile owns enough state and Messages that separating its update loop makes the application easier to navigate.

Both use the same contract. Internal state stays behind the boundary, and values crossing it have named roles. A Listbox can report that an item was selected without exposing its highlight index or focus bookkeeping. A feature Submodel can read shared parent state and surface domain facts without taking ownership of the whole application.

State and View Boundaries

The word "boundary"

Every Submodel has a state and update boundary: the parent stores its Model and folds its Messages without changing the child Model directly. When a Submodel owns a view, each h.submodel call also creates a runtime view boundary identified by slotId. Query is a viewless Submodel, so it has the state and update boundary without an h.submodel call or slotId.

The restaurant analogy

A Submodel is one station in the restaurant. The station owns its work and internal order state. The head waiter routes work to it and responds to the facts it reports without directing each step inside the station.

When a view function is enough

If a section only renders parent state and owns no Messages or update logic, make it a view function. A reusable card that receives a title and content does not need a Submodel.

The Child Submodel

A child Submodel does not know which parent embeds it. This Settings Submodel owns its state and handles its Messages without importing the root Model or Message.

Settings Submodel

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:

Parent Model
import { Schema } from 'effect'

import * as Settings from './page/settings'

export const Model = Schema.Struct({
  username: Schema.String,
  settings: Settings.Model,
})
export type Model = typeof Model.Type

Never Bypass the Child’s Update

The parent stores the child Model, but the child still owns it. Do not use modifyFields to change fields inside that slice from the parent.

❌ Resetting child state directly
// ❌ Don't reach into the child's Model from the parent's update.
// This bypasses Settings.update, so its invariants, Commands,
// and OutMessages are skipped.
ClickedResetSettings: () => ({
  model: modifyFields(model, {
    settings: settings => modifyFields(settings, { theme: () => 'Light' }),
  }),
})

Parent Entry Points

For a parent-initiated change, export a helper from the child and fold that helper with Update.foldChild. The parent can call Settings.setTheme without importing the internal ChangedTheme constructor.

✅ Resetting child state through update

Stateful Foldkit UI components expose the same kind of entry point. For example: Popover.close and a Listbox instance's selectItem helper run the component's update without exposing its internal Message constructors.

Messages produced by the child take the regular wrapper path described below. Helpers cover the opposite direction, when the parent needs to initiate a child transition.

What Direct Changes Skip

Bypassing update creates three problems:

  • The transition bypasses invariants enforced by the child's update.

  • Commands and OutMessages that the child's update would return are skipped.

  • A later change to the child Model can leave the parent writing obsolete fields.

Wrapping Messages

Every Message eventually reaches the root update. Each parent therefore declares a wrapper Message for the child Message type. Name it with the Got*Message convention, such as GotSettingsMessage.

Wrapper Message
import { Schema } from 'effect'
import { defineMessageUnion } from 'foldkit/message'

import * as Settings from './page/settings'

export const Message = defineMessageUnion({
  GotSettingsMessage: { message: Settings.Message },
})
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 carries routing information only. It holds the child message and, for repeated instances, an identifier such as entryId. Domain data belongs inside the child Message that uses it.

Folding Update with Update.foldChild

Update.foldChild is the update half of embedding a Submodel. Its configuration tells Foldkit how to:

  • run the child update;

  • read and write the child Model;

  • wrap result Messages from child Commands.

The resulting fold reads the child, runs its update, writes it back, and lifts its Commands through toParentMessage.

Using foldChild

Optional Children and Step Composition

read returns an Option because a routed page or child entry may be absent from the parent Model. When read returns None, the fold leaves the parent Model unchanged. An always-present child returns Option.some(model.settings).

The fold is dual. foldSettings(model, message) runs it immediately. foldSettings(message) returns an Update.Step<ParentModel, ParentMessage> for Update.combine. Close over per-dispatch context in the update field, and apply route gates before calling the fold.

Child Entry Points

Use Update.foldChildStep for an entry point that takes only the child Model, such as Dialog.close. It accepts the same fields and returns an Update.Step<ParentModel, ParentMessage>. Add toParentOutMessage when at least one child OutMessage should continue to the current Submodel's parent. The fold then returns an Update.StepWithOutMessage<ParentModel, ParentMessage, ParentOutMessage>.

Using foldChildStep with OutMessage forwarding

Child Initialization

Use Update.foldChildInit when one child init or boot result needs to enter a parent Model. It is data-first because initialization has no reusable data-last Step. Provide toParentModel instead of read and write; Foldkit lifts child Commands and gives foldOutMessage the completed parent Model. Say Settings.boot receives a saved theme from flags and emits RestoredTheme. The parent returns a Command to apply that theme after constructing its Model.

Using foldChildInit

Use Update.foldChildInits when several children initialize together. Its toParentModel receives every child Model before their OutMessage folds run. See Initializing Children with OutMessages for an example that combines both children's restoration results into one parent OutMessage.

Wiring the View with h.submodel

Defining the Child View

A viewless Submodel needs no runtime view wiring. Its parent can render values the child exposes through public accessors, such as Query's read. When a Submodel owns rendering, define its view with Submodel.defineView<Model, Message>. It receives the child Model and a builder for child Messages.

defineView brands the function with its child Model and Message types. The parent can then embed it without repeating those types, and handlers inside the child accept only child Messages.

Child view

Required Embed Fields

The parent passes four required fields to h.submodel:

  • slotId identifies this position under the current boundary.

  • model supplies the child Model.

  • view supplies the branded child view.

  • toParentMessage wraps a child Message for the parent.

Parent view

Any parent with the required Model and wrapper can embed the same Settings.view.

Per-render View Inputs

Parent-Owned Render Data

Use ViewInputs for parent-owned data the child needs only while rendering. A Listbox may need items and an item renderer, while its Model owns highlight and selection state.

Pass ViewInputs as the third type parameter to defineView. The view then receives (model, viewInputs, h).

Child view with view inputs

The parent supplies viewInputs at the embed site.

Parent view with view inputs

Keep state in the child Model and per-render configuration in viewInputs. The child changes its Model through update. The parent rebuilds viewInputs on each render.

Slot Callbacks

A top-level slot callback such as toView lets the parent choose markup while the child supplies state and attributes. Foldkit runs top-level viewInputs functions in the parent's boundary. A handler the parent builds inside the callback therefore dispatches a parent Message. childAttributes handles the opposite case, when child-owned attributes cross into that markup.

Keep slot callbacks at the top level

Functions nested inside an object or array in viewInputs throw when Foldkit builds the view. The error names the path, such as viewInputs.config.onSubmit. Move the function to the top level so Foldkit can run it in the parent's boundary.

Boundary Id and Model Identity

slotId identifies a rendered position, not a Model value. Every h.submodel call under one parent boundary needs a distinct slotId, even when two positions render the same child Model.

Fixed and Repeated Positions

For fixed positions, name the position: 'desktop-sidebar' and 'mobile-sidebar'. For a list, use the stable item identifier because each item occupies its own position.

Duplicate Slot IDs

Foldkit throws while building the view when sibling boundaries reuse a slotId.

Multiple Instances

Fixed and Dynamic Collections

A parent can hold a fixed or dynamic number of child instances.

For a fixed set, give each child its own Model field and slotId. For a dynamic set, start with an array. Use the same stable identifier for the row key, slotId, and wrapper Message.

Shared and Independent State

When at most one child instance is active, store one child Model and an Option of its key. A table with at most one open row menu can share one Menu Model. Use a collection when multiple instances need independent state at once, such as editors on several rows or progress for several uploads.

Folding a Child by Key

Folding a child in a collection

Selecting and Writing the Child

Use Update.foldChildAt to run a child update for one Submodel selected by key. readAt finds the child Model, and writeAt stores the next one. toParentMessage receives the key when wrapping the result Message of each child Command. If readAt returns None for that key, the fold leaves the parent Model unchanged. The job-application example stores education, work-history, and skills Submodels in arrays. Each Submodel has a stable entry ID that foldChildAt uses as its key.

OutMessages by Key

When the selected child emits an OutMessage, foldOutMessage takes the key and returns a matcher whose handlers produce parent Steps. If an OutMessage handler returns a child Command, give the foldOutMessage factory a second FoldContext parameter and use its lifters to wrap the Command's result Message. To forward an OutMessage, toParentOutMessage takes the key and returns a matcher that produces a parent OutMessage. Neither factory runs when the child emits no OutMessage.

Memoization Across Submodel Boundaries

Cache Boundary

By default, a parent render runs each child view again. If profiling finds repeated work in a long list or expensive child view, place the embed site behind createKeyedLazy from foldkit/html.

Foldkit keeps the boundary registration alive across cache hits and removes it when the VNode leaves the tree. Key the lazy view with the same stable identifier used by slotId.

Further Guidance

The View Memoization page covers cache identity, limits, and measurement.

Reading Parent State

Choosing the Input

Do not copy shared parent state into a child Model merely so the child can read it. Choose the boundary based on when the child needs the value:

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

The parent remains the single source of truth in both cases.

Passing Parent State to a Child Submodel’s view

Pass parent state through viewInputs when the child needs it for rendering. The parent supplies the current value on every render.

Parent state through viewInputs

Providing Parent State to a Child Submodel’s update

Add a third context argument when child update needs the current parent value while processing a Message. Close over that value when constructing the fold.

Parent context in child update

The update stays pure because the context is an explicit input. Constructing foldSettings(model.currentUser) for each dispatch gives the child the current user without storing a second copy.

Context does not notify the child when a value changes. If the child must react to that change, expose an inform* helper and fold it from the parent handler that observed the change. See Informing Submodels.

Surfacing Facts to the Parent

Wrapper Messages and OutMessages

Wrapper Messages route child work back into the child update. An OutMessage reports a fact the parent may need to act on, such as a committed date, selected tab, or completed login.

The child update can include an OutMessage in its optional outMessage field. The child describes what happened, and the parent decides the consequence. A Login Submodel can emit SucceededLogin without knowing how the root stores a session or changes the URL.

Defining OutMessages

Define OutMessages beside the child Message. Name them as past-tense facts: SucceededLogin, not TransitionToLoggedIn; RequestedLogout, not DoLogout.

OutMessage definition

Emitting from the Child

The child update returns its Model, optional Commands, and an optional OutMessage. Most branches omit outMessage. A branch includes it only when it has a fact to surface.

Child update

SubmittedLoginForm starts authentication but has no result to report. SucceededAuthenticate emits SucceededLogin({ sessionId }) after the Command completes.

Handling in the Parent

Folding OutMessages

Handle the OutMessage through foldOutMessage on Update.foldChild. Update.foldChildInit accepts the same OutMessage adapters when one child emits a fact during initialization. Update.foldChildInits applies those adapters to each named child after its shared toParentModel has constructed the parent Model. Bind the fold as a standalone fold<Child>OutMessage value and match on every OutMessage tag. The returned Update.Step<ParentModel, ParentMessage> receives the parent Model after the updated child has been written back or after toParentModel has constructed it.

Do not unpack a child update, helper, init, or boot result by hand. Destructuring model and commands can leave its outMessage behind without a type error. Dot access can still ignore an OutMessage, but an operation-named value keeps all three returned fields visible together. Use Update.foldChild, Update.foldChildStep, Update.foldChildInit, or Update.foldChildInits so the child Model, lifted Commands, and OutMessage remain part of one fold.

Using foldChild with foldOutMessage

Lifting Child Commands

The fold appends the Step's Commands after the child's lifted Commands. If the Step returns a child Command, use liftCommand or liftCommands from Update.FoldContext. The lifter wraps the Command's result Message with the same toParentMessage used by the child fold.

Fold Context Typing

When a foldChildInits entry needs that context, annotate both parameters, such as (outMessage: Search.OutMessage, { liftCommand }: Update.FoldContext<Search.Message, Message>). The folds record cannot infer the context's parent Message from the same entry's toParentMessage. The FoldContext example uses the same typed handler shape.

Parent-Owned Command Inputs

In this example, only the parent knows the redirect Route for Login.SendMagicLink. The child emits RequestedMagicLink, and the parent fills in the Route while keeping the Command result inside the Login boundary.

Using foldOutMessage with FoldContext

Update.foldChildStep supplies the same fold context for no-argument child entry points. It also accepts toParentOutMessage when the current Submodel forwards a child OutMessage to its parent.

Forwarding OutMessages

A parent Submodel adds toParentOutMessage only when it passes at least one child OutMessage up to its own parent. Match every child variant. Return the parent's OutMessage for each variant that should continue upward. Return undefined for each variant that stops at this Submodel. The Auth example forwards a successful login through two Submodel levels to the root.

If every child OutMessage stops at this Submodel, omit toParentOutMessage. Any foldOutMessage handling still runs. A forwarded OutMessage also runs through foldOutMessage, so the same event can update this Submodel before it continues upward. Do not add toParentOutMessage: () => undefined just to change the fold's return type.

Deriving Parent OutMessages

toParentOutMessage passes the child's fact upward one to one. When the parent's fact is derived instead, annotate the fold's return type as Update.StepWithOutMessage and return the parent OutMessage in outMessage. For example, a picker child emits SelectedDate, and the parent applies it to its own Model and emits CompletedRange only once both dates are set. This path needs no toParentOutMessage adapter.

When a fold both derives a parent OutMessage and has a one-to-one lift, the derived OutMessage replaces the lift for that dispatch. If the Step omits outMessage, the lift still runs. Update.foldChildStep, Update.foldChildInit, and each Update.foldChildInits entry follow the same rule.

Reflecting External State

Silent Inbound Changes

OutMessages move facts from child to parent. A reflect* helper handles the inbound direction, when an external source such as the URL, restored storage, or a sibling field requires the child to conform.

Return Contract

A reflect* helper returns the child Model directly. It does not return Commands or an OutMessage. The external value is already the source of truth, so emitting it back could create a write loop.

Point-Free Helpers

Define reflect helpers with Function.dual so they work point-free in modifyFields. Here the URL owns the price range, and the parent reflects that range onto a Slider.

Reflecting URL price bounds into a Slider
ChangedUrl: ({ route }) => ({
  model: modifyFields(model, {
    // The URL owns the price bounds, so reflect them onto the Slider. reflectRange
    // returns Model (point-free in modifyFields) and emits nothing, so it can't echo the
    // route back and loop.
    priceSlider: Slider.reflectRange({
      min: route.minPrice,
      max: route.maxPrice,
    }),
  }),
})

Ownership and Naming

Only the owner calls a child's reflect* helper. User interactions still go through the child update and may emit OutMessages.

External Ownership

External means outside this child boundary, not outside the application. For example: when a start date changes, the parent can call reflectMinDate on the end-date picker. The end-date child did not cause the change, but it must obey the new constraint.

Naming

Foldkit UI uses domain verbs such as selectItem and selectDate for user choices that can emit. Silent inbound setters use the reflect* prefix, including Calendar and DatePicker constraints and Slider's reflectRange.

Which Boundary a Handler Dispatches Through

Dispatch Rules

An element dispatches through the boundary where it is built. The builder carries that boundary, which is why every view receives h as an argument.

Inside a Submodel there are two frames, with opposite defaults:

Where you build the elementDispatches throughTo change it
The child’s own view bodythe child’s boundaryhave the parent supply the element through a viewInputs slot callback
A slot callback the parent passed in viewInputsthe parent’s boundarychildAttributes binds it back to the child

The child view normally builds child handlers. A slot callback normally builds parent handlers.

Shared Renderers

A shared helper may belong to a sibling Submodel or the parent itself. For example: documentation pages render a shared SnippetCopy Submodel and heading links owned by the application shell. Let the parent build those renderers and pass them through top-level viewInputs callbacks.

Shared renderers

The callbacks run in the parent's boundary. The heading link reaches the parent update directly, while each snippet button establishes its own child boundary and produces GotSnippetCopyMessage for the parent to fold.

Builder Lifetime

Thread h through view functions. Never store it in module state. A stored builder can outlive its render boundary and fail when a handler dispatches.

childAttributes

Some Submodels let the parent render the DOM while the child supplies behavior. Tooltip, Dialog, Popover, and selection Submodels publish attribute bundles through a slot callback. The parent chooses the elements and styling.

Those child-built handlers must still dispatch through the child boundary after the parent spreads them onto an element. childAttributes preserves that routing.

The Problem

Imagine a CommandMenu builds h.OnClick(OpenedMenu()) but asks the parent to render the button. The click must produce GotCommandMenuMessage({ message: OpenedMenu() }) for the parent fold.

Without the child dispatcher, the parent-built button would send raw OpenedMenu() to the parent update. The child would never receive its own Message.

How It Works

childAttributes brands each attribute with the current child dispatcher. When the parent builds an element, its constructor uses the branded dispatcher for those attributes instead of the parent dispatcher.

The child publishes branded attribute groups with the state the slot needs.

Publishing child attributes

The parent consumes that slot data without reading the child Model.

Rendering child attributes

The child OnClick uses the carried dispatcher. Parent attributes such as h.Class behave normally.

When to Reach For It

Consumers do not call childAttributes. Spread the bundle the Submodel provides.

Authors must wrap every child-owned attribute group before publishing it to a parent slot. Otherwise, handlers route through the parent boundary and skip the child update.

Render helpers don’t need this

Stateless helpers such as Button and controlled helpers such as Checkbox are not Submodels. Their Messages already belong to the consumer boundary, so they do not use childAttributes.

Testing Submodels

Child Tests

Test child update with Story and child view with Scene. Both accept the same shapes at the child level that they accept at the root.

Use expectOutMessage for a child's OutMessage. For an update with context, close over the test context: (model, message) => Settings.update(model, message, { currentUser }).

Parent Tests

Test the parent when the behavior crosses the boundary, such as routing a wrapper by id or folding an OutMessage. See Testing for choosing the level.

Debugging Submodels in DevTools

Wrapper Discovery

The Got*Message convention powers the Submodel filter in Foldkit DevTools. Selecting a child scopes the timeline and Model diffs to that boundary.

Another wrapper name still routes correctly, but DevTools cannot discover it for the filter.

OutMessages in History

An OutMessage is processed within the parent fold. Any later parent Message appears as its own timeline entry.

Common Pitfalls

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

  • Duplicate slotId during view construction: Two sibling h.submodel calls identify the same position. See Boundary Id and Model Identity.

  • Child Message reaches the root but nothing changes: The parent Message union or update is missing the wrapper variant. See Wrapping Messages and Folding Update.

  • Wrapper missing from the DevTools filter: The wrapper does not follow the Got*Message convention. See Debugging Submodels.

  • Child view sees stale parent state: Parent state was copied into the child Model. Pass it through viewInputs instead. See Reading Parent State.

  • Child-owned handler uses the parent boundary: The child published attributes without childAttributes. See childAttributes.

  • Error names a nested viewInputs path: Move the nested callback to the top level of viewInputs.

  • A long child list rerenders slowly: Profile it before adding createKeyedLazy. See Memoization.

These checks catch wiring mistakes. See Anti-patterns for code that compiles but lets the parent own a child's state or internal Messages.

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, and types the builder the runtime passes the view as its last parameter. The <Model, Message> parameters are required at the definition site; ViewInputs is optional and, when supplied, makes the view take a second viewInputs argument before the builder. Also exported as defineView from foldkit/html.

Submodel.View

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

The branded view type produced by Submodel.defineView. Without ViewInputs the shape is (model, h) => Html. 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.