Skip to main content
All Examples

Counters

A dynamic list of Counter Submodels. Add and remove rows; each row is an independent Submodel embedded via h.submodel, with per-instance routing via a wrapper Message.

Submodels
/
import { Array, Match as M, Option, Schema as S } from 'effect'
import { Command, Runtime } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import * as Counter from './counter'

// MODEL

const Row = S.Struct({
  id: S.String,
  counter: Counter.Model,
})
type Row = typeof Row.Type

export const Model = S.Struct({
  rows: S.Array(Row),
  nextRowId: S.Number,
})
export type Model = typeof Model.Type

// MESSAGE

export const ClickedAddRow = m('ClickedAddRow')
export const ClickedRemoveRow = m('ClickedRemoveRow', { id: S.String })

export const GotCounterMessage = m('GotCounterMessage', {
  id: S.String,
  message: Counter.Message,
})

export const Message = S.Union([
  ClickedAddRow,
  ClickedRemoveRow,
  GotCounterMessage,
])
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({
      ClickedAddRow: () => [
        evo(model, {
          rows: Array.append({
            id: `counter-${model.nextRowId}`,
            counter: Counter.init,
          }),
          nextRowId: nextRowId => nextRowId + 1,
        }),
        [],
      ],
      ClickedRemoveRow: ({ id }) => [
        evo(model, {
          rows: Array.filter(row => row.id !== id),
        }),
        [],
      ],
      GotCounterMessage: ({ id, message }) =>
        Option.match(
          Array.findFirst(model.rows, row => row.id === id),
          {
            onNone: () => [model, []],
            onSome: row => {
              const [nextCounter, commands] = Counter.update(
                row.counter,
                message,
              )
              return [
                evo(model, {
                  rows: Array.map(existingRow =>
                    existingRow.id === id
                      ? evo(existingRow, { counter: () => nextCounter })
                      : existingRow,
                  ),
                }),
                Command.mapMessages(commands, childMessage =>
                  GotCounterMessage({ id, message: childMessage }),
                ),
              ]
            },
          },
        ),
    }),
  )

// INIT

export const init: Runtime.ProgramInit<Model, Message> = () => [
  {
    rows: [
      { id: 'counter-0', counter: Counter.init },
      { id: 'counter-1', counter: Counter.init },
      { id: 'counter-2', counter: Counter.init },
    ],
    nextRowId: 3,
  },
  [],
]

// VIEW

const rowView = (row: Row): Html => {
  const h = html<Message>()

  return h.keyed('div')(
    row.id,
    [h.Class('flex items-center gap-2')],
    [
      h.div(
        [h.Class('flex-1')],
        [
          h.submodel({
            slotId: row.id,
            model: row.counter,
            view: Counter.view,
            toParentMessage: message =>
              GotCounterMessage({ id: row.id, message }),
          }),
        ],
      ),
      h.button(
        [
          h.OnClick(ClickedRemoveRow({ id: row.id })),
          h.Class(
            'rounded border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:border-red-300 hover:text-red-600 transition cursor-pointer',
          ),
        ],
        ['Remove'],
      ),
    ],
  )
}

export const view = (model: Model): Document => {
  const h = html<Message>()

  return {
    title: `Counters (${model.rows.length})`,
    body: h.div(
      [
        h.Class(
          'min-h-screen bg-white flex flex-col items-center py-12 px-6 gap-6',
        ),
      ],
      [
        h.h1([h.Class('text-2xl font-semibold text-gray-900')], ['Counters']),
        h.p(
          [h.Class('text-sm text-gray-500 max-w-md text-center')],
          [
            'Each row is a Counter Submodel. The parent has no awareness of Counter internals; it just embeds the Submodel via h.submodel and routes dispatched messages back to the right row via the GotCounterMessage wrapper.',
          ],
        ),
        h.div(
          [h.Class('flex flex-col gap-3 w-full max-w-md')],
          model.rows.map(rowView),
        ),
        h.button(
          [
            h.OnClick(ClickedAddRow()),
            h.Class(
              'rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 transition cursor-pointer',
            ),
          ],
          ['+ Add Counter'],
        ),
      ],
    ),
  }
}