Skip to main content
Foldkit
On this pageScaling with Submodels

Advanced Patterns

As your Foldkit app grows, these patterns help you manage complexity while keeping code organized and maintainable.

Scaling with Submodels

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.

Submodule Structure

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

Parent Responsibilities

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

The 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.Type

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

Model as Union

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

In 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.Type

Parent-Child Communication with OutMessage

Sometimes 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.Type

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

View Memoization

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

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

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.

When to Use Lazy Views

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.