On this pageOverview
Foldkit vs Elm: Side by Side
We built the same pixel art editor (try it live) in both Foldkit and Elm. Same features, same styling, same algorithms: grid drawing with undo/redo stacks, three tools with mirror modes, flood fill, localStorage persistence, PNG export, keyboard shortcuts, and a time-travel history panel.
Foldkit and Elm agree about architecture, so this page is a translation guide, not an argument. Foldkit is the Elm Architecture, implemented in TypeScript on top of Effect: a single Model, Messages as facts, a pure update function, side effects as data the runtime executes. If you know Elm, you already know how a Foldkit app is shaped. What differs is the host language and what each side gets from it.
And to be clear about the direction of respect: Elm is where this architecture comes from. Elm still provides guarantees Foldkit cannot match, and this page says so plainly where it matters.
Read them both
The Foldkit version is in the examples gallery. The Elm version source is on GitHub: idiomatic Elm 0.19, no npm dependencies, just elm make.
Here is the whole translation table. Every row is a direct conceptual match.
| Elm | Foldkit | |
|---|---|---|
| State | Model | Model (Schema struct) |
| Events | Msg custom type | Message union (Schema) |
| Transitions | update : Msg -> Model -> ( Model, Cmd Msg ) | update(model, message): [Model, Command[]] |
| Side effects | Cmd Msg (opaque) | Command (a named, inspectable value) |
| Event streams | Sub Msg | Subscription (Effect Stream) |
| Boot data | Flags (Json.Decode.Value) | Flags (Schema, an Effect) |
| JS interop | Ports, custom elements | n/a (the app is already JavaScript) |
| Nested state | Nested TEA (by hand) | Submodel (first-class) |
The rest of this page walks through the rows where the differences are interesting. Where a row is boring (in the best way), we say so and move on.
The Elm Msg type has 21 variants. It reads exactly like you would expect:
type Msg
= PressedCell Int Int
| EnteredCell Int Int
| LeftCanvas
| ReleasedMouse
| SelectedColor Int
| SelectedTool Tool
| SelectedGridSize Int
| ToggledMirrorHorizontal
| ToggledMirrorVertical
| ClickedUndo
| ClickedRedo
| ClickedHistoryStep Int
| ClickedRedoStep Int
| ClickedClear
| ClickedExport
| FailedExportPng String
| DismissedErrorDialog
| ConfirmedGridSizeChange
| DismissedGridSizeDialog
| SelectedPaletteTheme Int
| ToggledThemePickerThe Foldkit Message union has 29. Same naming convention, same facts, same role as the total input domain of the update function:
const PressedCell = m('PressedCell', { x: S.Number, y: S.Number })
const EnteredCell = m('EnteredCell', { x: S.Number, y: S.Number })
const LeftCanvas = m('LeftCanvas')
const ReleasedMouse = m('ReleasedMouse')
const SelectedColor = m('SelectedColor', { colorIndex: PaletteIndex })
const SelectedTool = m('SelectedTool', { tool: Tool })
const SelectedGridSize = m('SelectedGridSize', { size: S.Number })
const ToggledMirrorHorizontal = m('ToggledMirrorHorizontal')
const ToggledMirrorVertical = m('ToggledMirrorVertical')
const ClickedUndo = m('ClickedUndo')
const ClickedRedo = m('ClickedRedo')
const ClickedHistoryStep = m('ClickedHistoryStep', { stepIndex: S.Number })
const ClickedRedoStep = m('ClickedRedoStep', { stepIndex: S.Number })
const ClickedClear = m('ClickedClear')
const ClickedExport = m('ClickedExport')
const SucceededExportPng = m('SucceededExportPng')
const FailedExportPng = m('FailedExportPng', { error: S.String })
const DismissedErrorDialog = m('DismissedErrorDialog')
const GotErrorDialogMessage = m('GotErrorDialogMessage', {
message: Ui.Dialog.Message,
})
const ConfirmedGridSizeChange = m('ConfirmedGridSizeChange')
const DismissedGridSizeConfirmDialog = m('DismissedGridSizeConfirmDialog')
const GotGridSizeConfirmDialogMessage = m('GotGridSizeConfirmDialogMessage', {
message: Ui.Dialog.Message,
})
const GotToolRadioGroupMessage = m('GotToolRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
const GotGridSizeRadioGroupMessage = m('GotGridSizeRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
const GotPaletteRadioGroupMessage = m('GotPaletteRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
const GotMirrorHorizontalSwitchMessage = m('GotMirrorHorizontalSwitchMessage', {
message: Ui.Switch.Message,
})
const GotMirrorVerticalSwitchMessage = m('GotMirrorVerticalSwitchMessage', {
message: Ui.Switch.Message,
})
const GotThemeListboxMessage = m('GotThemeListboxMessage', {
message: Ui.Listbox.Message,
})
const CompletedSaveCanvas = m('CompletedSaveCanvas')
const Message = S.Union([
PressedCell,
EnteredCell,
LeftCanvas,
ReleasedMouse,
SelectedColor,
SelectedTool,
SelectedGridSize,
ToggledMirrorHorizontal,
ToggledMirrorVertical,
ClickedUndo,
ClickedRedo,
ClickedHistoryStep,
ClickedRedoStep,
ClickedClear,
ClickedExport,
SucceededExportPng,
FailedExportPng,
DismissedErrorDialog,
GotErrorDialogMessage,
ConfirmedGridSizeChange,
DismissedGridSizeConfirmDialog,
GotGridSizeConfirmDialogMessage,
GotToolRadioGroupMessage,
GotGridSizeRadioGroupMessage,
GotPaletteRadioGroupMessage,
GotMirrorHorizontalSwitchMessage,
GotMirrorVerticalSwitchMessage,
GotThemeListboxMessage,
CompletedSaveCanvas,
])
type Message = typeof Message.TypeWhy 29 against 21? The difference is structural, not stylistic. Foldkit Commands report back: CompletedSaveCanvas and SucceededExportPng exist because every Command resolution returns to update as a Message. In Elm, a fire-and-forget port has no completion Msg unless you wire one through another port. Eight more are Got*Message variants delegating to Foldkit UI Submodels (Dialog, RadioGroup, Switch, Listbox). The Elm version hand-rolls those components, so their events collapse into the app’s own Msg variants, including two Msgs Foldkit doesn’t need: ToggledThemePicker and SelectedPaletteTheme, because the shipped Listbox tracks its own open state and reports selection through its Got*Message.
One real difference hides in the type definitions. Elm Msgs are constructors of a custom type: they exist at runtime as opaque tagged values. Foldkit Messages are Schema values: serializable, validatable, printable. That’s what lets Foldkit DevTools log, diff, and replay them, and what makes sending Messages over a wire a non-event.
This is the section where you should feel at home. The two update functions are the same function wearing different syntax.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
PressedCell x y ->
case model.tool of
Brush ->
( { model
| grid = applyBrush x y model
, undoStack = Grid.pushHistory model.grid model.undoStack
, redoStack = []
, isDrawing = True
}
, Cmd.none
)
Fill ->
withSave
{ model
| grid = Grid.floodFill x y model.selectedColorIndex model.grid
, undoStack = Grid.pushHistory model.grid model.undoStack
, redoStack = []
}
Eraser ->
-- ...
ClickedUndo ->
case model.undoStack of
[] ->
( model, Cmd.none )
previousGrid :: olderGrids ->
withSave
{ model
| grid = previousGrid
, undoStack = olderGrids
, redoStack = model.grid :: model.redoStack
}
-- ... 19 more branches
withSave : Model -> ( Model, Cmd Msg )
withSave model =
( model, saveCanvas (encodeSavedCanvas model) )export const update = (
model: Model,
message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
M.value(message).pipe(
withUpdateReturn,
M.tagsExhaustive({
PressedCell: ({ x, y }) =>
M.value(model.tool).pipe(
withUpdateReturn,
M.when('Brush', () => [
evo(model, {
grid: () => applyBrush(model, x, y),
undoStack: () => pushHistory(model.undoStack, model.grid),
redoStack: () => [],
isDrawing: () => true,
}),
[],
]),
M.when('Fill', () => {
const nextModel = evo(model, {
grid: () => applyFill(model, x, y),
undoStack: () => pushHistory(model.undoStack, model.grid),
redoStack: () => [],
})
return [nextModel, [saveCanvas(nextModel)]]
}),
// ...
),
ClickedUndo: () =>
Array.match(model.undoStack, {
onEmpty: () => [model, []],
onNonEmpty: nonEmptyUndoStack => {
const nextModel = evo(model, {
grid: () => Array.lastNonEmpty(nonEmptyUndoStack),
undoStack: () => Array.initNonEmpty(nonEmptyUndoStack),
redoStack: () => [...model.redoStack, model.grid],
})
return [nextModel, [saveCanvas(nextModel)]]
},
}),
// ... 28 more handlers
}),
)Pattern match on the message, return new state plus effects. case msg of becomes M.tagsExhaustive. Record update syntax becomes evo, which preserves references for unchanged fields exactly the way Elm’s record update does (that reference stability is what makes memoization work in both frameworks; more below). ( model, Cmd.none ) becomes [model, []].
Exhaustiveness is worth a closer look. Elm’s compiler enforces it at the language level: a missing case is a compile error, full stop. Foldkit gets the same failure mode through M.tagsExhaustive, which makes a missing Message handler a type error. Both stop you at compile time. The difference is that Elm’s check is unconditional, while Foldkit’s applies wherever update is written with M.tagsExhaustive, which is how every Foldkit program in these docs is written.
The Elm Model is a record of custom types. Impossible states are unrepresentable in both versions: the error dialog is open exactly when exportError is Just, and the confirm dialog is open exactly when pendingGridSize is Just. No booleans to desynchronize.
type Tool
= Brush
| Fill
| Eraser
type MirrorMode
= MirrorNone
| MirrorHorizontal
| MirrorVertical
| MirrorBoth
type alias Model =
{ grid : Grid
, undoStack : List Grid
, redoStack : List Grid
, selectedColorIndex : Int
, gridSize : Int
, tool : Tool
, mirrorMode : MirrorMode
, isDrawing : Bool
, hoveredCell : Maybe Position
, exportError : Maybe String
, paletteThemeIndex : Int
, pendingGridSize : Maybe Int
, isThemePickerOpen : Bool
}The Foldkit Model is the same shape declared as an Effect Schema struct, with Option where Elm has Maybe, plus Submodel fields for the UI components:
import { Schema as S } from 'effect'
import { Ui } from 'foldkit'
export const Model = S.Struct({
grid: Grid,
undoStack: S.Array(Grid),
redoStack: S.Array(Grid),
selectedColorIndex: PaletteIndex,
gridSize: S.Number,
tool: Tool,
mirrorMode: MirrorMode,
isDrawing: S.Boolean,
maybeHoveredCell: S.Option(Position),
errorDialog: Ui.Dialog.Model,
maybeExportError: S.Option(S.String),
paletteThemeIndex: S.Number,
gridSizeConfirmDialog: Ui.Dialog.Model,
maybePendingGridSize: S.Option(S.Number),
toolRadioGroup: Ui.RadioGroup.Model,
gridSizeRadioGroup: Ui.RadioGroup.Model,
paletteRadioGroup: Ui.RadioGroup.Model,
mirrorHorizontalSwitch: Ui.Switch.Model,
mirrorVerticalSwitch: Ui.Switch.Model,
themeListbox: Ui.Listbox.Model,
})Why declare the Model as a Schema instead of a plain type? Because a Schema is a type that also exists at runtime. It validates data at the boundaries (the localStorage round-trip below), derives encoders and decoders instead of making you write them, and gives DevTools a faithful, serializable picture of your whole application state. Elm’s type system is cleaner to read, and it is sound, which TypeScript’s does not even aim to be. Foldkit’s buys you machinery.
Here is the deepest real difference between the two frameworks, and this app exercises it twice: saving to localStorage and exporting a PNG. Neither is possible in pure Elm, because Elm code cannot touch browser APIs that aren’t wrapped by an official package. Both go through ports.
port module Main exposing (Msg(..), defaultModel, main, update)
-- The Elm side: ports declare that JavaScript exists, nothing more.
port saveCanvas : Encode.Value -> Cmd msg
port requestExportPng : Encode.Value -> Cmd msg
port exportPngFailed : (String -> msg) -> Sub msg
-- In update: send a request out, receive the failure (if any) back
-- as a Msg through the subscription.
ClickedExport ->
( model, requestExportPng (encodeExportRequest model) )
FailedExportPng error ->
( { model | exportError = Just error }, Cmd.none )And the JavaScript half, which lives in index.html:
// The JavaScript side, in index.html. This code is invisible to the
// Elm compiler. If it throws, drifts out of sync with the encoder, or
// forgets to call send(), Elm cannot know.
app.ports.saveCanvas.subscribe(function (data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch (error) {
// Silently fail on storage errors
}
})
app.ports.requestExportPng.subscribe(function (request) {
try {
var canvas = document.createElement('canvas')
var context = canvas.getContext('2d')
if (context === null) {
throw new Error('Canvas 2D context not available')
}
// ... paint request.pixels onto the canvas, then download ...
link.click()
} catch (error) {
app.ports.exportPngFailed.send(
error instanceof Error ? error.message : 'Failed to export image',
)
}
})Ports are a deliberate, principled design, and they deliver something remarkable: JavaScript on the other side of a port can throw, hang, or lie, and your Elm app cannot crash. The boundary is absolute. That’s the pitch, and it’s real.
The cost is also real. The export feature is now two codebases: an encoder and port declaration in Elm, and a JavaScript handler somewhere else that the Elm compiler has never heard of. The failure path needs its own port. Rename a field in the encoder and the JavaScript silently receives the old shape of nothing. You can write the JavaScript side in TypeScript and type the port payloads by hand (tools like elm-ts-interop generate those types), but the two sides remain separate programs: nothing checks them against each other.
Foldkit doesn’t have a JS boundary because the whole app is already in the JS ecosystem. Both effects are Commands: named values wrapping an Effect, with the export logic (an offscreen canvas element, a download link) written inline, typed, and tested alongside everything else.
const SaveCanvas = Command.define(
'SaveCanvas',
{ model: Model },
CompletedSaveCanvas,
)(({ model }) =>
Effect.gen(function* () {
const store = yield* KeyValueStore.KeyValueStore
yield* store.set(STORAGE_KEY, encode(model))
return CompletedSaveCanvas()
}).pipe(
Effect.catch(() => Effect.succeed(CompletedSaveCanvas())),
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
),
)
const ExportPng = Command.define(
'ExportPng',
{ grid: Grid, gridSize: S.Number, theme: PaletteTheme },
SucceededExportPng,
FailedExportPng,
)(({ grid, gridSize, theme }) =>
Effect.gen(function* () {
const context = yield* getCanvasContext(gridSize)
paintGrid(context, grid, theme)
downloadAsPng(context)
return SucceededExportPng()
}).pipe(
Effect.catchTag('FailedExportPng', Effect.succeed),
Effect.catch(() =>
Effect.succeed(FailedExportPng({ error: 'Failed to export image' })),
),
),
)What replaces the safety?
Elm’s purity at this boundary is enforced by the compiler; Foldkit’s by the architecture: side effects exist only inside Commands, every Command resolves to a Message (including failures, via Effect.catch), and the update function stays pure. You lose the compiler’s absolute wall and gain the ability to call any npm library three lines from your update function, with the failure path in the same file as the effect.
Both apps persist the canvas to localStorage and restore it through flags at boot. That means a JSON round-trip, and the two frameworks make you pay for it differently.
Elm requires a decoder for the way in and an encoder for the way out, written separately. The compiler checks each one against your types, but nothing checks them against each other:
init : Decode.Value -> ( Model, Cmd Msg )
init flags =
case Decode.decodeValue savedCanvasDecoder flags of
Ok saved ->
( { defaultModel
| grid = saved.grid
, gridSize = saved.gridSize
, paletteThemeIndex = saved.paletteThemeIndex
, selectedColorIndex = saved.selectedColorIndex
}
, Cmd.none
)
Err _ ->
( defaultModel, Cmd.none )
savedCanvasDecoder : Decode.Decoder SavedCanvas
savedCanvasDecoder =
Decode.map4 SavedCanvas
(Decode.field "grid" gridDecoder)
(Decode.field "gridSize" Decode.int)
(Decode.field "paletteThemeIndex" Decode.int)
(Decode.field "selectedColorIndex" Decode.int)
gridDecoder : Decode.Decoder Grid
gridDecoder =
Decode.array (Decode.array (Decode.nullable Decode.int))
-- And the encoder, written by hand in the other direction:
encodeSavedCanvas : Model -> Encode.Value
encodeSavedCanvas model =
Encode.object
[ ( "grid", encodeGrid model.grid )
, ( "gridSize", Encode.int model.gridSize )
, ( "paletteThemeIndex", Encode.int model.paletteThemeIndex )
, ( "selectedColorIndex", Encode.int model.selectedColorIndex )
]In Foldkit the SavedCanvas Schema is declared once, and both directions are derived from it:
// The Schema is the single source of truth. The decoder and the
// encoder both fall out of it. They cannot drift apart.
export const SavedCanvas = S.Struct({
grid: SavedGrid,
gridSize: S.Number,
paletteThemeIndex: S.Number,
selectedColorIndex: PaletteIndex,
})
export const SavedCanvasJsonString = S.fromJsonString(
S.toCodecJson(SavedCanvas),
)
export const flags: Effect.Effect<Flags> = Effect.gen(function* () {
const store = yield* KeyValueStore.KeyValueStore
const json = yield* Effect.fromOption(
Option.fromNullishOr(yield* store.get(STORAGE_KEY)),
)
const decoded = yield* S.decodeEffect(SavedCanvasJsonString)(json)
return Flags.make({ maybeSavedCanvas: Option.some(decoded) })
}).pipe(
Effect.catch(() =>
Effect.succeed(Flags.make({ maybeSavedCanvas: Option.none() })),
),
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
)
// Saving goes through the same Schema:
// S.encodeSync(SavedCanvasJsonString)(data)Elm decoders are precise and composable, and many Elm developers genuinely like writing them. But they are a maintenance surface: every field exists in three places (the type, the decoder, the encoder), and the compiler verifies each against the type but not against each other’s field names. Decode.field "gridSize" and Encode.object [ ( "gridsize", … ) ] typecheck fine and lose your data. Schema collapses the three places into one.
This is the section with the least to translate. Both frameworks derive active event listeners from Model state, and the runtime handles subscribe and unsubscribe as the Model changes. The mouse-release listener exists only while the user is drawing, in both apps, with no manual listener management in either.
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Browser.Events.onKeyDown (keyboardDecoder model)
, if model.isDrawing then
Browser.Events.onMouseUp (Decode.succeed ReleasedMouse)
else
Sub.none
, exportPngFailed FailedExportPng
]
keyboardDecoder : Model -> Decode.Decoder Msg
keyboardDecoder model =
Decode.map5 KeyEvent
(Decode.field "key" Decode.string)
(Decode.field "ctrlKey" Decode.bool)
(Decode.field "metaKey" Decode.bool)
(Decode.field "shiftKey" Decode.bool)
(Decode.field "altKey" Decode.bool)
|> Decode.andThen (shortcutFor model)
-- shortcutFor maps the decoded event to a Msg, or fails the
-- decoder for keys the app does not care about.export const subscriptions = Subscription.make<Model, Message>()(entry => ({
keyboard: Subscription.persistent(
Stream.fromEventListener<KeyboardEvent>(document, 'keydown').pipe(
Stream.mapEffect(handleKeyboardEvent),
Stream.filterMap(Function.identity),
),
),
mouseRelease: entry(
{ isDrawing: S.Boolean },
{
modelToDependencies: model => ({ isDrawing: model.isDrawing }),
dependenciesToStream: ({ isDrawing }) =>
Stream.when(
Stream.fromEventListener(document, 'mouseup').pipe(
Stream.map(() => ReleasedMouse()),
),
Effect.sync(() => isDrawing),
),
},
),
}))The differences are at the edges. Elm subscriptions are limited to what official packages expose (Browser.Events, Time, ports); anything else means a port. Foldkit Subscriptions are Effect Streams, so any event source you can reach from JavaScript (WebSockets, observers, third-party SDKs) can feed Messages directly, with the Stream combinators for filtering and mapping along the way. Note also the third entry in the Elm list: exportPngFailed FailedExportPng is port plumbing. The Foldkit version has no equivalent because Command failures already return as Messages.
Both apps render a 32×32 grid (1024 cells) at 60fps during paint strokes, and both get there the same way: reference-equality memoization at panel and row boundaries. If you’ve used Html.Lazy, you already know how Foldkit memoization works.
toolbarView : Model -> PaletteTheme -> Html Msg
toolbarView model theme =
div [ class "w-full md:w-44 flex flex-col gap-5 flex-shrink-0" ]
[ lazy toolSection model.tool
, lazy mirrorSection model.mirrorMode
, lazy sizeSection model.gridSize
, paletteSection model theme
, lazy clearCanvasSection model.grid
]
-- The canvas keys each row and wraps it in lazy5. A row only
-- re-renders when one of its five arguments changes by reference.
Html.Keyed.node "div"
[ class "cursor-crosshair select-none w-full aspect-square flex flex-col bg-white" ]
(Grid.toRows model.grid
|> List.map
(\( y, row ) ->
( String.fromInt y
, lazy5 rowView
y
row
previewColor
(rowPreviewPositions y previewPositions)
theme.colors
)
)
)const lazyHeader = createLazy()
const lazyToolPanel = createLazy()
const lazyHistoryPanel = createLazy()
const lazyRow = createKeyedLazy()
// Each args array is compared element-by-element against the previous render.
// If every arg is reference-equal, the view function isn't called at all.
// evo() preserves references for unchanged Model fields, so the check just works.
export const view = (model: Model): Document => ({
title: 'Pixel Art',
body: div(
[],
[
lazyHeader(headerView, []),
lazyToolPanel(toolPanelView, [
model.mirrorMode,
model.selectedColorIndex,
isGridEmpty(model.grid),
model.toolRadioGroup,
]),
canvasView(model, theme),
lazyHistoryPanel(historyPanelView, [
model.undoStack,
model.redoStack,
currentGrid,
model.gridSize,
theme,
]),
],
),
})lazy and createLazy do the identical check: if the arguments are reference-equal to last render, skip the view function. Elm’s record update and Foldkit’s evo both preserve unchanged references, so in both frameworks the check passes for everything a Message didn’t touch. The mechanical difference: Elm’s lazy family is arity-indexed (lazy through lazy8, and the wrapped function itself must be a top-level reference), while createLazy takes an args array and is created once at module level.
One layer down, the per-cell code is where the two frameworks look most alike, because both treat messages as values at the event boundary. There are no handler closures to stabilize in either:
rowView : Int -> Array Cell -> String -> List Int -> List String -> Html Msg
rowView y row previewColor previewColumns paletteColors =
div [ class "flex flex-1" ]
(Array.toIndexedList row
|> List.map
(\( x, cell ) ->
let
displayColor =
if List.member x previewColumns then
previewColor
else
cellColor paletteColors cell
in
cellView x y displayColor
)
)
cellView : Int -> Int -> String -> Html Msg
cellView x y backgroundColor =
div
[ onMouseDown (PressedCell x y)
, onMouseEnter (EnteredCell x y)
, style "flex" "1"
, style "background-color" backgroundColor
]
[]const rowView = (
row: ReadonlyArray<Cell>,
y: number,
previewColor: HexColor,
previewPositions: ReadonlyArray<readonly [number, number]>,
theme: PaletteTheme,
): Html =>
div(
[Style({ display: 'flex', flex: '1' })],
Array.map(row, (cell, x) => {
const isPreview = previewPositions.some(
([previewX, previewY]) => previewX === x && previewY === y,
)
const displayColor = isPreview ? previewColor : resolveColor(cell, theme)
return div(
[
OnMouseDown(PressedCell({ x, y })),
OnMouseEnter(EnteredCell({ x, y })),
Style({ flex: '1', backgroundColor: displayColor }),
],
[],
)
}),
)The Elm version hand-rolls its dialogs, radio groups, switches, and theme listbox: the markup, the ARIA attributes, the open/closed state in the Model, the Escape key in the keyboard decoder, the backdrop click. Elm makes that work pleasant, but it’s on you, and the hand-rolled versions in this app cover less ground than a production component library (no focus trapping, no arrow-key navigation in the radio groups). Community packages exist, and elm-ui takes a different road entirely, but there is no standard accessible component kit.
Foldkit ships UI components that are themselves little Elm Architecture programs: each has a Model, Messages, and an update function, and you compose them as Submodels. Focus management, ARIA, keyboard navigation, and transitions come built in, and their state lives in your Model where DevTools and tests can see it. If you ever wrote nested TEA in Elm (the triple of init/update/view, the Cmd.map/Html.map plumbing), Submodels are exactly that pattern with the plumbing standardized.
| Elm (this app) | Foldkit | |
|---|---|---|
| Dialog, RadioGroup, Switch, Listbox | Hand-rolled views + Model fields | Shipped components, composed as Submodels |
| Accessibility | Whatever you write | Built-in (aria, focus, keyboard) |
| Component state | Yours, in the Model | Yours, in the Model |
| Wiring pattern | Nested TEA by hand | Same pattern, built into the framework |
Testing the update function is a pleasure in both frameworks, for the same reason: it’s a pure function, so a test is just calling it. The difference shows up the moment a side effect matters.
suite : Test
suite =
test "undo restores the previous grid state" <|
\() ->
let
-- The Cmd in each returned tuple is discarded with `_`.
-- A Cmd is opaque: there is no way to look inside one,
-- so there is no way to assert that ReleasedMouse
-- actually triggered a save.
( afterPress, _ ) =
update (PressedCell 0 0) defaultModel
( afterRelease, _ ) =
update ReleasedMouse afterPress
( afterUndo, _ ) =
update ClickedUndo afterRelease
in
Expect.all
[ \model -> Expect.equal (Grid.cellAt 0 0 model.grid) Nothing
, \model -> Expect.equal model.undoStack []
, \model -> Expect.equal (List.length model.redoStack) 1
]
afterUndoLook at the underscores. Each update call returns a Cmd and the test throws it away, because a Cmd is opaque by design: no equality, no pattern matching, no way to ask “was that the save command?”. Delete the save from ReleasedMouse and this test stays green. The Elm community’s answer is elm-program-test, which is excellent but requires restructuring your program around simulatable effect types to use fully.
test('undo restores the previous grid state', () => {
Story.story(
update,
Story.with(emptyModel),
Story.message(PressedCell({ x: 0, y: 0 })),
Story.message(ReleasedMouse()),
// If someone removes the SaveCanvas command from ReleasedMouse, this
// test fails. You can't accidentally delete a side effect without
// every test that depends on it telling you. That's the point: side
// effects are load-bearing, and your tests enforce it automatically.
Story.Command.resolve(SaveCanvas, CompletedSaveCanvas()),
Story.model(model => {
expect(model.grid[0]?.[0]).toEqual(Option.some(0))
expect(model.undoStack).toHaveLength(1)
}),
Story.message(ClickedUndo()),
Story.Command.resolve(SaveCanvas, CompletedSaveCanvas()),
Story.model(model => {
expect(model.grid[0]?.[0]).toEqual(Option.none())
expect(model.undoStack).toHaveLength(0)
expect(model.redoStack).toHaveLength(1)
}),
)
})Because Commands are plain values, Story tests assert on them and resolve them inline, in the same synchronous pipeline as the Model assertions. Remove the SaveCanvas Command from the update function and this test fails. No program restructuring required: the production update function is already in the shape the test needs. Scene extends the same idea to interaction tests against the virtual DOM, querying by accessible role, no jsdom.
If you move from Elm to Foldkit, these are real losses.
Enforced purity. Nothing in TypeScript stops a teammate from calling Date.now() in the middle of a Foldkit update function. The framework’s conventions, Story tests, and review culture catch it; in Elm it cannot be written at all.
No runtime exceptions. Elm’s flagship claim holds up in practice. Foldkit confines effects and models failures as Messages, and Effect makes error channels explicit, but the host language can still throw, and dependencies from npm can do anything at all.
A small language. Elm fits in your head. No any, no type assertions, no six ways to write a function, one formatter with no options, and packages that cannot perform side effects. TypeScript plus Effect is a far larger surface, and Foldkit’s conventions are doing work the Elm language does by construction.
Smaller bundles. Dead code elimination is Elm’s home turf. elm make --optimize produces famously tiny assets. A Foldkit app carries the Effect runtime; tree-shaking helps, but Elm wins this one comfortably.
The npm ecosystem, without a boundary. Every chart library, auth SDK, websocket client, and date library is a direct import inside a Command. The pixel art app’s canvas export went from “a port, an encoder, and a script tag” to a typed function next to the update that triggers it. This is the single biggest practical difference, and it compounds with every feature.
Gradual adoption, both directions. Elm embeds in an existing page too, talking to the host through flags and ports. A Foldkit program embedded in a TypeScript app shares its types and calls its modules directly; nothing is serialized across a boundary.
Schema as one source of truth. Types that validate, encode, and decode. No hand-written decoder/encoder pairs to keep in sync, and runtime validation at every boundary where the outside world hands you data.
Commands you can hold. Elm’s Cmd is opaque to your tests and your debugger. Foldkit Commands are named values: DevTools shows every Command next to the Message that produced it, and tests assert on them directly. Elm’s time-traveling debugger shows you every Msg and Model; Foldkit DevTools shows you those plus the effects.
Effect underneath. Retries, timeouts, concurrency, resource safety, and structured error handling are library primitives you compose inside Commands, not patterns you reinvent with Cmds and Msgs.
Shipped UI components. The accessible component kit Elm leaves to the community comes in the box, built on the same architecture as your app.
If Elm fits your constraints (greenfield frontend, a team excited by it, light interop needs), it remains one of the best ways to build a web application, and nothing on this page argues otherwise. Foldkit exists for the situations where Elm’s walled garden is the dealbreaker: a TypeScript codebase you can’t leave, npm dependencies you can’t wrap, teammates you can’t retrain.
The bet Foldkit makes is that the Elm Architecture is the most durable idea in frontend, worth carrying into the ecosystem where most teams actually live, even at the cost of trading guarantees the compiler enforces for guarantees you would have to go out of your way to break. You can audit that trade concretely: read the Elm source and the Foldkit source side by side. They are recognizably the same program. That’s the point.