Skip to main content
On this pageOverview

Submodels

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.

The Submodels pattern lets you decompose your app into self-contained modules, each with its own Model, Message, and update — the same pieces you already know, just scoped to a single feature.

In the restaurant analogy, think of a large restaurant with multiple stations — a sushi bar, a grill, 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 naturally 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 interaction between parent and child is visible in the update function.

The Child Module

A child module has its own Model, Message, and update. Here’s a Settings module that manages theme preferences:

// 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 Model = S.Struct({
  theme: S.String,
})

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,
): 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 }), []],
    }),
  )

Nothing here knows about the parent. The child manages its own state and handles its own Messages. This isolation is the point — you can develop, test, and reason about each module independently.

Parent Responsibilities

The parent has three jobs: embed the child’s Model, wrap the child’s Messages, and delegate to the child’s 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

Wrapping Messages

In Foldkit, every Message is a top-level Message — the runtime only delivers Messages to your app’s update function. There’s no built-in message routing to child modules. Instead, the parent creates a wrapper Message that carries the child’s Message inside it. By convention, these 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.

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:

import { Array, Effect, 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 = Array.map(
          commands,
          Command.mapEffect(
            Effect.map(message => GotSettingsMessage({ message })),
          ),
        )

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

The Command mapping deserves attention. The child’s Commands produce child Messages when they complete — but the Foldkit runtime expects top-level Messages. The child doesn’t wrap its own Commands because it could be used across many parents. So the parent uses Command.mapEffect to wrap each result in GotSettingsMessage, translating child Messages back into the parent’s Message type. Command.mapEffect transforms the inner Effect while preserving the Command’s name — so traces still show the original name from the child module.

Multiple instances

If you need several instances of the same child (e.g. three accordions), embed each as a separate field. For a dynamic number, use an array and include an ID in the wrapper Message to route updates to the correct instance.

See the Shopping Cart example for a complete Submodels implementation. But what happens when a Message in the child should trigger a change in the parent’s Model — like a switch from logged-out to logged-in in the root Model, or an item added to a cart in a sibling Submodel? The child can’t update parent state and shouldn’t know about it. That’s what OutMessage solves.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson