Skip to main content
On this pageOverview

Selection Submodels

Overview

Foldkit UI ships five Submodels for selecting one or more values from a set: Listbox, Combobox, RadioGroup, Tabs, and Menu. For example, a Listbox of plans, a Combobox of cities, a RadioGroup of pricing tiers, a Tabs of view modes, or a Menu of actions.

Each exposes a create<Item>() factory that pairs the view and update behind a single type parameter, so the value type is fixed at the binding site and flows into the OutMessage.

A Listbox over a literal-union Plan type:

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

type Plan = 'Free' | 'Pro' | 'Enterprise'

// Declare a typed Listbox once at module scope. `view` and `update` are
// bound to `Plan`: `items` is typed as `ReadonlyArray<Plan>` and the
// OutMessage carries `value: Plan`.
const PlanListbox = Ui.Listbox.create<Plan>()

// Add a field to your Model for the Listbox Submodel, plus a field for
// the selected value your app actually cares about:
const Model = S.Struct({
  maybePlan: S.Option(S.String),
  listbox: Ui.Listbox.Model,
  // ...your other fields
})

// In your init function, initialize the Listbox Submodel with a unique id:
const init = () => [
  {
    maybePlan: Option.none(),
    listbox: Ui.Listbox.init({ id: 'plan' }),
    // ...your other fields
  },
  [],
]

// Wrap Listbox's Messages so they can flow through your update:
const GotListboxMessage = m('GotListboxMessage', {
  message: Ui.Listbox.Message,
})

// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to PlanListbox.update. The
// third tuple element is `Option<OutMessage>`; when the user commits a
// selection it carries `Selected({ value })` where `value: Plan`:
GotListboxMessage: ({ message }) => {
  const [nextListbox, commands, maybeOutMessage] = PlanListbox.update(
    model.listbox,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotListboxMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [evo(model, { listbox: () => nextListbox }), mappedCommands],
    onSome: M.type<Ui.Listbox.OutMessage<Plan>>().pipe(
      M.tagsExhaustive({
        Selected: ({ value }) => [
          evo(model, {
            listbox: () => nextListbox,
            maybePlan: () => Option.some(value),
          }),
          mappedCommands,
        ],
      }),
    ),
  })
}

const plans: ReadonlyArray<Plan> = ['Free', 'Pro', 'Enterprise']

// Inside your view function, embed the Listbox via h.submodel using
// `PlanListbox.view`:
const view = (model: Model) => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'plan',
    model: model.listbox,
    view: PlanListbox.view,
    viewInputs: {
      // `items` must be ReadonlyArray<Plan>. The factory's <Plan> parameter constrains the shape.
      items: plans,
      buttonContent: h.span(
        [],
        [Option.getOrElse(model.maybePlan, () => 'Select a plan')],
      ),
      buttonClassName: 'w-full rounded-lg border px-3 py-2 text-left',
      itemsClassName: 'rounded-lg border shadow-lg',
      itemToConfig: (plan, { isSelected, isActive }) => ({
        className: isActive ? 'bg-blue-100' : '',
        content: h.div(
          [h.Class('flex items-center gap-2 px-3 py-2')],
          [
            isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
            h.span([], [plan]),
          ],
        ),
      }),
      backdropClassName: 'fixed inset-0',
      anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
    },
    toParentMessage: message => GotListboxMessage({ message }),
  })
}

The create<Item>() Factory

A call to Ui.Listbox.create<Plan>() returns an object whose entry points are all bound to Plan: view accepts items: ReadonlyArray<Plan>, update returns an OutMessage carrying the picked Plan, and the imperative helpers the Submodel exposes (selectItem, open, and close for Listbox and Combobox; select for RadioGroup; selectTab for Tabs) accept and emit Plan too. Declare the factory once at module scope and use the same bundle at every site that needs it.

For the inbound direction, the factory also exposes reflectSelectedItem (Listbox and Combobox), reflectSelectedValue (RadioGroup), and reflectSelectedTab (Tabs), which mirror an external value (a URL parameter, restored storage, a server push) onto the selection without emitting an OutMessage. See Reflecting External State for the concept.

The Submodel Doesn’t Own Your Selection

A common first question is: if the Listbox is Item-typed, why does my own Model still hold an Option<Plan> for the picked value? Isn’t that the same state twice?

It isn’t. The Listbox’s Model is UI state: open vs. closed, which option the keyboard is focused on, the typeahead key buffer. It deliberately does not hold the committed selection, because committed selections are domain truth. The Submodel hands you that truth at the moment the user commits via the OutMessage; your update lifts it into your own Model, where it belongs.

That split is why the Listbox Model can stay generic-free (no Listbox.Model<Item>) while Item still flows into your code with full type safety. The generic threads through the boundary (items in, OutMessage item out), and nowhere else. If the selection needs to persist, store it in your Model. If the commit just dispatches a Command (for example a Menu of actions), no Model field is needed.

Why the Factory Exists

Without the factory, the view and update would each carry their own Item type parameter. Nothing would stop a consumer from writing view: Ui.Listbox.view<Plan>() next to Ui.Listbox.update<Color>(...). Two different type arguments at the same call site. The selected item would arrive in the OutMessage typed as one and the update would believe it was the other, and TypeScript would have no way to flag the mismatch.

The factory closes that hole by setting Item once. The returned view and update are bound to the same Item because both come from the same factory call.

Internally, each Submodel’s view and update are written against an untyped string value and then cast back to the consumer’s Item at the factory boundary. The cast is sound because the value being emitted came from the same items array the consumer just supplied. The fence is items in → items out, same type.

The Factories

Each Submodel exposes a create<...>() factory. The shape of the type parameter differs by what the Submodel accepts as items.

Listbox

Ui.Listbox.create<Item, Value?>() takes two type parameters. Item is the shape of the items the consumer supplies. Value is the shape of the value the OutMessage carries; it defaults to Item when Item extends string, else string. The two-parameter shape supports object-typed items via an itemToValue callback that extracts the stringy identifier from each Item.

Ui.Listbox.Multi.create<Item, Value?>() is the multi-select variant. Same type-parameter shape; the Selected OutMessage gains a wasAdded: boolean field that tells the parent whether the user selected or deselected the value.

Combobox

Ui.Combobox.create<Item>() takes one type parameter and constrains Item extends string. Combobox items are typed strings (a literal union, a branded string type, or plain string).

Ui.Combobox.Multi.create<Item>() is the multi-select variant. Same type-parameter shape; the Selected OutMessage gains a wasAdded: boolean field that tells the parent whether the user selected or deselected the value.

RadioGroup

Ui.RadioGroup.create<Value>() takes one type parameter, Value extends string. The view accepts ReadonlyArray<string> as its options (a literal union Value is assignable to string), and the OutMessage carries the picked value: Value. The single parameter is enough because RadioGroup options are always inline strings; there is no object form.

Tabs

Ui.Tabs.create<Value>() takes one type parameter, Value extends string. The view accepts ReadonlyArray<Value> as its tab list (a literal union Value is assignable to string), and the OutMessage carries both the picked value: Value and its index: number. The single parameter is enough because Tabs values are always inline strings; there is no object form.

Ui.Menu.create<Item>() takes one type parameter, Item extends string. The view accepts ReadonlyArray<Item> as its menu items, and the OutMessage carries both the picked value: Item and its index: number. The picked value arrives directly in the OutMessage, so consumers no longer need to look it up from their own items array.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson