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, pipe } from 'effect'
import { Command, Runtime, Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Button } from '@foldkit/ui'

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

const foldCounter = (id: string) =>
  Update.foldChild({
    update: Counter.update,
    read: (model: Model) =>
      pipe(
        Array.findFirst(model.rows, row => row.id === id),
        Option.map(row => row.counter),
      ),
    write: (model, nextCounter) =>
      evo(model, {
        rows: Array.map(row =>
          row.id === id ? evo(row, { counter: () => nextCounter }) : row,
        ),
      }),
    toParentMessage: message => GotCounterMessage({ id, message }),
  })

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 }) => foldCounter(id)(model, message),
    }),
  )

// INIT

export const init: Runtime.ApplicationInit<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, h: HtmlBuilder<Message>): Html =>
  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 }),
          }),
        ],
      ),
      Button.view(
        {
          onClick: ClickedRemoveRow({ id: row.id }),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                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'],
            ),
        },
        h,
      ),
    ],
  )

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  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(row => rowView(row, h)),
      ),
      Button.view(
        {
          onClick: ClickedAddRow(),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                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'],
            ),
        },
        h,
      ),
    ],
  ),
})