On this pageWhen to Create a Submodel
Submodel
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, andListboxuse 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.
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.
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.
The parent has three jobs: embed the child’s Model, wrap its Messages, and delegate to its update.
The child’s Model becomes a field in the parent’s 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.TypeThe parent stores the child Model, but the child still owns it. Do not use modifyFields to change fields inside that slice from the parent.
// ❌ 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' }),
}),
})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.
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.
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.
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.
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.TypeDevTools 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.
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.
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.
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>.
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.
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.
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.
The parent passes four required fields to h.submodel:
slotIdidentifies this position under the current boundary.modelsupplies the child Model.viewsupplies the branded child view.toParentMessagewraps a child Message for the parent.
Any parent with the required Model and wrapper can embed the same Settings.view.
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).
The parent supplies viewInputs at the embed site.
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.
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.
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.
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.
Foldkit throws while building the view when sibling boundaries reuse a slotId.
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.
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.
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.
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.
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.
The View Memoization page covers cache identity, limits, and measurement.
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
viewInputsonh.submodel.When a child update needs context from the parent, add a third
contextargument to the child’s update.
The parent remains the single source of truth in both cases.
Pass parent state through viewInputs when the child needs it for rendering. The parent supplies the current value on every render.
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.
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.
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.
Define OutMessages beside the child Message. Name them as past-tense facts: SucceededLogin, not TransitionToLoggedIn; RequestedLogout, not DoLogout.
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.
SubmittedLoginForm starts authentication but has no result to report. SucceededAuthenticate emits SucceededLogin({ sessionId }) after the Command completes.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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,
}),
}),
})Only the owner calls a child's reflect* helper. User interactions still go through the child update and may emit OutMessages.
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.
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.
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 element | Dispatches through | To change it |
|---|---|---|
| The child’s own view body | the child’s boundary | have the parent supply the element through a viewInputs slot callback |
A slot callback the parent passed in viewInputs | the parent’s boundary | childAttributes binds it back to the child |
The child view normally builds child handlers. A slot callback normally builds parent handlers.
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.
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.
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.
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.
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.
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.
The parent consumes that slot data without reading the child Model.
The child OnClick uses the carried dispatcher. Parent attributes such as h.Class behave normally.
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.
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 }).
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.
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.
An OutMessage is processed within the parent fold. Any later parent Message appears as its own timeline entry.
Issues new Submodel users hit, and where to read about the fix:
Duplicate
slotIdduring view construction: Two siblingh.submodelcalls 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*Messageconvention. See Debugging Submodels.Child view sees stale parent state: Parent state was copied into the child Model. Pass it through
viewInputsinstead. See Reading Parent State.Child-owned handler uses the parent boundary: The child published attributes without
childAttributes. See childAttributes.Error names a nested
viewInputspath: Move the nested callback to the top level ofviewInputs.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.
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.
The configuration record passed to h.submodel.
| Name | Type | Default | Description |
|---|---|---|---|
slotId | string | — | 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. |
model | View extends SubmodelView<infer Model, ...> ? Model : never | — | The child Submodel’s slice of the parent Model. Type is inferred from the branded view. |
view | SubmodelView<Model, Message, ViewInputs?> | — | The child’s exported view, branded via Submodel.defineView so the embed site can infer the child’s Message type. |
viewInputs | ViewInputs | 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<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<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<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 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.