On this pageScaling with Submodels
Advanced Patterns
As your Foldkit app grows, these patterns help you manage complexity while keeping code organized and maintainable.
As your app grows, a single Model/Message/Update becomes unwieldy. The submodel pattern lets you split your app into self-contained modules, each with its own Model, Message, init, update, and view.
Each submodule has its own Model, Message, and update:
import { Match as M, Schema as S } from 'effect'
import { Command } from 'foldkit/command'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
// MODEL
export const Model = S.Struct({
theme: S.String,
notifications: S.Boolean,
})
export type Model = typeof Model.Type
// MESSAGE
export const ChangedTheme = m('ChangedTheme', { theme: S.String })
export const Message = S.Union(ChangedTheme)
export type Message = typeof Message.Type
// UPDATE
export const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.tagsExhaustive({
ChangedTheme: ({ theme }) => [
evo(model, { theme: () => theme }),
[],
],
}),
)The parent model embeds the child model as a field:
import { Schema as S } from 'effect'
import * as Settings from './page/settings'
export const Model = S.Struct({
username: S.String,
settings: Settings.Model,
})
export type Model = typeof Model.TypeThe parent has a wrapper message that contains the child message:
import { Schema as S } from 'effect'
import { m } from 'foldkit/message'
import * as Settings from './page/settings'
export const GotSettingsMessage = m('GotSettingsMessage', {
message: Settings.Message,
})
export const Message = S.Union(GotSettingsMessage)
export type Message = typeof Message.TypeIn update, delegate to the child and rewrap returned commands:
import { Array, Effect, Match as M } from 'effect'
import { Command } from 'foldkit/command'
import { evo } from 'foldkit/struct'
export const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.tagsExhaustive({
GotSettingsMessage: ({ message }) => {
const [nextSettings, commands] = Settings.update(
model.settings,
message,
)
const mappedCommands = Array.map(
commands,
Effect.map(message => GotSettingsMessage({ message })),
)
return [
evo(model, { settings: () => nextSettings }),
mappedCommands,
]
},
}),
)See the Shopping Cart example for a complete implementation of this pattern.
When your app has mutually exclusive states—like logged in vs logged out, wizard steps, or game phases—you can model your root state as a union of variants rather than embedding submodels in a struct.
Define each variant as a tagged struct, then combine them with S.Union:
import { Schema as S } from 'effect'
import { ts } from 'foldkit/schema'
const LoggedOut = ts('LoggedOut', {
email: S.String,
password: S.String,
})
const LoggedIn = ts('LoggedIn', {
userId: S.String,
username: S.String,
})
export const Model = S.Union(LoggedOut, LoggedIn)
export type Model = typeof Model.TypeIn the view, use Match.tagsExhaustive to handle each variant:
import { Match as M } from 'effect'
export const view = (model: Model) =>
M.value(model).pipe(
M.tagsExhaustive({
LoggedOut: renderLoginForm,
LoggedIn: renderDashboard,
}),
)To transition between states, return a different variant from update:
import { Match as M } from 'effect'
import { Command } from 'foldkit/command'
export const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.tagsExhaustive({
ClickedLogin: () => [
LoggedIn({ userId: '123', username: 'alice' }),
[],
],
ClickedLogout: () => [
LoggedOut({ email: '', password: '' }),
[],
],
}),
)See the Auth example for a complete implementation.
If you need shared state across union variants, wrap the union in a struct:
import { Schema as S } from 'effect'
export const Model = S.Struct({
theme: S.String,
authState: S.Union(LoggedOut, LoggedIn),
})
export type Model = typeof Model.TypeSometimes a child module needs to trigger a change in the parent's state. Child modules cannot directly update parent state—they only manage their own. The OutMessage pattern solves this—children emit semantic events that parents then handle.
Define OutMessage schemas alongside your child Message:
import { Schema as S } from 'effect'
import { m } from 'foldkit/message'
// MESSAGE
export const ClickedLogout = m('ClickedLogout')
export const Message = S.Union(ClickedLogout)
export type Message = typeof Message.Type
// OUT MESSAGE
export const RequestedLogout = m('RequestedLogout')
export const OutMessage = S.Union(RequestedLogout)
export type OutMessage = typeof OutMessage.TypeThe child update function returns a 3-tuple: model, commands, and an optional OutMessage:
import { Match as M, Option } from 'effect'
import { Command } from 'foldkit/command'
export const update = (
model: Model,
message: Message,
): [
Model,
ReadonlyArray<Command<Message>>,
Option.Option<OutMessage>,
] =>
M.value(message).pipe(
M.tagsExhaustive({
ClickedLogout: () => [
model,
[],
Option.some(RequestedLogout()),
],
}),
)The parent handles the OutMessage with Option.match, taking action when present:
import { Match as M, Option } from 'effect'
import { evo } from 'foldkit/struct'
export const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.tagsExhaustive({
GotSettingsMessage: ({ message }) => {
const [nextSettings, commands, maybeOutMessage] =
Settings.update(model.settings, message)
return Option.match(maybeOutMessage, {
onNone: () => [
evo(model, { settings: () => nextSettings }),
commands,
],
onSome: outMessage =>
M.value(outMessage).pipe(
M.tagsExhaustive({
RequestedLogout: () => [
LoggedOut({ email: '', password: '' }),
[...commands, clearSession()],
],
}),
),
})
},
}),
)Use Array.map with Effect.map to wrap child commands in parent message types:
import { Array, Effect } from 'effect'
const [nextSettings, commands, maybeOutMessage] = Settings.update(
model.settings,
message,
)
const mappedCommands = Array.map(commands, command =>
Effect.map(command, message => GotSettingsMessage({ message })),
)OutMessages are semantic events (like LoginSucceeded) while commands are side effects. This separation keeps child modules focused on their domain while parents handle cross-cutting concerns. See the Auth example for a complete implementation.
In the Elm Architecture, every model change triggers a full call to view(model). The entire virtual DOM tree is rebuilt from scratch, then diffed against the previous tree to compute minimal DOM updates. For most apps this is fast enough, but when a view contains a large subtree that rarely changes, the cost of rebuilding and diffing that subtree on every render adds up.
Foldkit provides two functions for skipping unnecessary view work: createLazy for single views and createKeyedLazy for lists. Both work by caching the VNode returned by a view function. When the function reference and all arguments are referentially equal (===) to the previous call, the cached VNode is returned without re-running the view function. Snabbdom's diff algorithm short-circuits when it sees the same VNode reference, so both VNode construction and subtree diffing are skipped.
createLazy creates a single memoization slot. Call it at module level to create a cache, then use it in your view to wrap an expensive subtree:
import { createLazy, html } from 'foldkit/html'
const { div, h2, p, ul, li } = html<Message>()
// Define the view function at module level for a stable reference.
// If defined inside the view, a new function is created each render,
// defeating the cache.
const statsView = (
revenue: number,
orderCount: number,
topProducts: ReadonlyArray<string>,
) =>
div(
[],
[
h2([], ['Dashboard']),
p([], [`Revenue: ${revenue}`]),
p([], [`Orders: ${orderCount}`]),
ul(
[],
topProducts.map(name => li([], [name])),
),
],
)
// Create the lazy slot at module level — one slot per view
const lazyStats = createLazy()
// In your main view, wrap the call with the lazy slot.
// If revenue, orderCount, and topProducts are the same references
// as last render, the cached VNode is returned instantly —
// both VNode construction and subtree diffing are skipped.
const view = (model: Model) =>
div(
[],
[
headerView(model),
lazyStats(statsView, [
model.revenue,
model.orderCount,
model.topProducts,
]),
sidebarView(model),
],
)Both the view function and the lazy slot must be defined at module level. If the view function is defined inside the view, a new function reference is created on every render, which means the fn === previousFn check always fails and the cache is never used.
Arguments are compared by reference, not by value. This works naturally with evo — when a model field isn't updated, evo preserves its reference. Only fields that actually changed get new references, so unchanged arguments automatically pass the === check.
createKeyedLazy creates a Map-backed cache where each key gets its own independent memoization slot. This is designed for lists where individual items change independently:
import { Array, Option } from 'effect'
import { createKeyedLazy, html } from 'foldkit/html'
const { div, li, span, ul } = html<Message>()
// Define the per-item view at module level
const contactView = (
name: string,
email: string,
isSelected: boolean,
) =>
li(
[],
[
span([], [name]),
span([], [email]),
...(isSelected ? [span([], ['✓'])] : []),
],
)
// Create the keyed lazy map at module level.
// Each key gets its own independent cache slot.
const lazyContact = createKeyedLazy()
// When rendering a list, only items whose args changed are recomputed.
// If you select a different contact, only the previously-selected
// and newly-selected items re-render — all others return cached VNodes.
const contactListView = (
contacts: ReadonlyArray<Contact>,
maybeSelectedId: Option.Option<string>,
) =>
ul(
[],
Array.map(contacts, contact => {
const isSelected = Option.exists(
maybeSelectedId,
selectedId => selectedId === contact.id,
)
return lazyContact(contact.id, contactView, [
contact.name,
contact.email,
isSelected,
])
}),
)When one item in the list changes, only that item is recomputed. All other items return their cached VNodes instantly. This turns an O(n) view rebuild into O(1) for the common case where only one or two items change.
Lazy views help most when:
- A large view subtree changes infrequently relative to how often the parent re-renders
- A list has many items but only a few change at a time (table of contents, contact lists, dashboards)
- The view function is expensive to compute (deeply nested trees, many elements)
Lazy views are unnecessary for small views, views that change on every model update, or leaf nodes with minimal children. The memoization check itself has a small cost, so applying it everywhere would add overhead without benefit.
How it works under the hood
Foldkit's virtual DOM library (Snabbdom) compares the old and new VNode by reference before diffing. When oldVnode === newVnode, it returns immediately — no attribute comparison, no child reconciliation, no DOM touching. createLazy and createKeyedLazy exploit this by returning the exact same VNode object when inputs are unchanged.