Bundle / Bundle and Submodel

One child, placed twice

A timer: a button that starts and pauses it, and a Subscription that ticks once a second while it runs. A page places it twice, for work and for rest.

The child: an ordinary Submodel

This is a plain Foldkit Submodel, with nothing from foldkit-bundle in it: a Model, a Message, init, update, Subscriptions, and a view made with Submodel.defineView so a parent can embed it with h.submodel.

ts
import { Option, Schema, Stream } from 'effect'
import * as Command from 'foldkit/command'
import type { HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import * as Submodel from 'foldkit/submodel'
import * as Subscription from 'foldkit/subscription'
import * as Update from 'foldkit/update'

const TimerModel = Schema.Struct({ seconds: Schema.Number, running: Schema.Boolean })
type TimerModel = typeof TimerModel.Type
const TimerMessage = defineMessageUnion({ ClickedToggle: {}, Ticked: {} })
type TimerMessage = typeof TimerMessage.Type

const init = (): Update.Return<TimerModel, TimerMessage> => ({
  model: { seconds: 0, running: false },
})

const update = (model: TimerModel, message: TimerMessage) =>
  TimerMessage.match<Update.Return<TimerModel, TimerMessage>>(message, {
    ClickedToggle: () => ({ model: { ...model, running: !model.running } }),
    Ticked: () => ({ model: { ...model, seconds: model.seconds + 1 } }),
  })

const subscriptions = Subscription.make<TimerModel, TimerMessage>()(entry => ({
  ticks: entry(
    { running: Schema.Boolean },
    {
      modelToDependencies: model => ({ running: model.running }),
      dependenciesToStream: ({ running }) =>
        running ? Stream.map(Stream.tick('1 second'), () => TimerMessage.Ticked()) : Stream.empty,
    },
  ),
}))

const view = Submodel.defineView<TimerModel, TimerMessage>((model, h) =>
  h.button(
    [h.OnClick(TimerMessage.ClickedToggle())],
    [`${model.seconds}s ${model.running ? 'Pause' : 'Start'}`],
  ),
)

Both versions below use this child unchanged. Only the parent differs.

Placed by hand

The parent declares a field and a wrapper Message for each placement, then wires each one in four places:

ts
const Model = Schema.Struct({ work: TimerModel, rest: TimerModel })
type Model = typeof Model.Type
const Message = defineMessageUnion({
  GotWorkMessage: { message: TimerMessage },
  GotRestMessage: { message: TimerMessage },
})
type Message = typeof Message.Type

// 1. init: each child's Model, and its Commands lifted
const parentInit = (): Update.Return<Model, Message> => {
  const work = init()
  const rest = init()
  return {
    model: { work: work.model, rest: rest.model },
    commands: [
      ...Command.mapMessages(work.commands, message => Message.GotWorkMessage({ message })),
      ...Command.mapMessages(rest.commands, message => Message.GotRestMessage({ message })),
    ],
  }
}

// 2. update: one fold per placement, and a route to each
const foldWork = Update.foldChild({
  update,
  read: (model: Model) => Option.some(model.work),
  write: (model: Model, work: TimerModel) => ({ ...model, work }),
  toParentMessage: (message: TimerMessage): Message => Message.GotWorkMessage({ message }),
})
const foldRest = Update.foldChild({
  update,
  read: (model: Model) => Option.some(model.rest),
  write: (model: Model, rest: TimerModel) => ({ ...model, rest }),
  toParentMessage: (message: TimerMessage): Message => Message.GotRestMessage({ message }),
})
const parentUpdate = (model: Model, message: Message) =>
  Message.match<Update.Return<Model, Message>>(message, {
    GotWorkMessage: ({ message }) => foldWork(model, message),
    GotRestMessage: ({ message }) => foldRest(model, message),
  })

// 3. subscriptions: one lift per placement, renamed so the keys do not collide
const work = Subscription.lift(subscriptions)({
  read: (model: Model) => Option.some(model.work),
  toParentMessage: (message: TimerMessage): Message => Message.GotWorkMessage({ message }),
})
const rest = Subscription.lift(subscriptions)({
  read: (model: Model) => Option.some(model.rest),
  toParentMessage: (message: TimerMessage): Message => Message.GotRestMessage({ message }),
})
const parentSubscriptions = { workTicks: work.ticks, restTicks: rest.ticks }

// 4. view: one h.submodel per placement
const parentView = (model: Model, h: HtmlBuilder<Message>) =>
  h.div(
    [],
    [
      h.submodel({
        slotId: 'work',
        model: model.work,
        view,
        toParentMessage: message => Message.GotWorkMessage({ message }),
      }),
      h.submodel({
        slotId: 'rest',
        model: model.rest,
        view,
        toParentMessage: message => Message.GotRestMessage({ message }),
      }),
    ],
  )

Every line is correct, and nothing checks that it is complete. Leave out the rest lift and the page still compiles; the rest timer's button toggles, but the timer never counts. Both lifts produce a key ticks, so they have to be renamed before they can share one record.

Placed as a Bundle

Bundle.make collects the same child's parts into one value:

ts
import { Bundle } from 'foldkit-bundle'

const Timer = Bundle.make('Timer', {
  Model: TimerModel,
  Message: TimerMessage,
  init,
  update,
  subscriptions: () => subscriptions,
  view,
})

The parent places it twice and wires the assembly once:

ts
const Page = Bundle.compose({}).pipe(
  Bundle.withChild('work', Timer),
  Bundle.withChild('rest', Timer),
)
type Model = typeof Page.Model.Type
type Message = typeof Page.Message.Type
const { children, placements } = Page

const config = placements.complete({
  init: () => placements.initial({}),
  update: placements.update(),
  subscriptions: placements.subscriptions(),
  view: (model: Model, h: HtmlBuilder<Message>) =>
    h.div([], [children.work.view(model, h), children.rest.view(model, h)]),
})

Bundle.compose derives the same parent the hand-wired version declared: the fields work and rest, and the Message with GotWorkMessage and GotRestMessage. Each placement is still the Submodel above: children.work.view draws it with h.submodel, placements.update folds it with Foldkit's fold, and placements.subscriptions lifts its ticks under the key Timer@work/ticks. The difference is that you did not write any of it, so none of it can be left out.