On this pageRecognition speed vs readability
Why no JSX?
Foldkit is plain TypeScript. There is no JSX, no transform, no compiler step. The view is built with a typed function-call DSL. Developers coming from JSX often ask why Foldkit doesn’t use it. This is the answer.
When a developer says JSX is easier to read, they usually mean they read it faster. That measurement is real. After years of working in JSX, an angle bracket lights up neurons that a function call does not. That is recognition speed. It belongs to the reader, not to the syntax.
Readability is a property of the code: how completely it communicates what it does, what it accepts, and what it can produce. A typed function call wins that comparison. Every attribute is a known constructor. Every event handler returns a known Message type. Children are a typed array, not an opaque variadic.
Familiarity is real, but it is a complaint about ramp-up, not about the syntax. A week into using the DSL, the recognition gap closes and unfamiliarity stops being the bottleneck.
Each HTML element is a function: div, button, p, input. Each one returns Html. Attributes are passed as an array of typed values, children as an array of Html | string. Event handlers like OnClick and OnInput produce typed Messages. The html() factory is parameterized by your app's Message type, so every handler in the resulting tree is constrained to produce a Message that belongs to that union. The compiler enforces it.
For the full tour of how views work, see View.
A button with a click handler in JSX:
function SaveButton({
isSaving,
onSave,
}: {
isSaving: boolean
onSave: () => void
}) {
return (
<button type="button" disabled={isSaving} onClick={onSave}>
Save
</button>
)
}The same button in the Foldkit DSL:
import { Schema as S } from 'effect'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
const ClickedSave = m('ClickedSave')
const Message = S.Union(ClickedSave)
type Message = typeof Message.Type
// In a real app, this destructure lives once in html.ts and is imported everywhere.
const { button, Type, Disabled, OnClick } = html<Message>()
const saveButton = (isSaving: boolean) =>
button([Type('button'), Disabled(isSaving), OnClick(ClickedSave())], ['Save'])The shapes are different. OnClick does not take a function. It takes a value of the Message type. That value flows through the entire app, gets logged in DevTools, replays in tests, and lands in update. JSX reaches into closures. The DSL hands you a fact.
An email input in JSX:
function EmailInput({
email,
onChange,
}: {
email: string
onChange: (value: string) => void
}) {
return (
<input
type="email"
value={email}
placeholder="you@example.com"
onChange={e => onChange(e.target.value)}
/>
)
}The same input in the DSL:
import { Schema as S } from 'effect'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
const InputtedEmail = m('InputtedEmail', { value: S.String })
const Message = S.Union(InputtedEmail)
type Message = typeof Message.Type
// In a real app, this destructure lives once in html.ts and is imported everywhere.
const { input, Type, Value, Placeholder, OnInput } = html<Message>()
const emailInput = (email: string) =>
input([
Type('email'),
Value(email),
Placeholder('you@example.com'),
OnInput(value => InputtedEmail({ value })),
])In JSX you write (e) => onChange(e.target.value). The handler signature leaks the SyntheticEvent shape into your code. In the DSL, OnInput(value => ...) extracts the value for you. The handler only cares about the data you actually want.
The DSL ships typed handlers for the standard HTML event surface. OnPointerDown hands you pointerType, button, screenX, screenY, clientX, clientY. OnFileChange hands you a list of files with metadata. OnKeyDown hands you the key and a typed modifier set.
The natural follow-up question is: what if I need a field a typed handler does not expose? Today you cannot reach it through the DSL. The set of handlers is closed. In practice this is rarely the limit you hit, because the curated payloads cover the fields you usually want. The places it does bite are specialized: pen pressure on pointer events for drawing apps, isComposing on input events for IME-aware text editors, multi-touch gesture data, and custom events dispatched by third-party widgets. We plan to add a typed escape hatch the way Elm does, with a decoder that fails safely on missing fields, before v1.0.0 ships. Until then, the set is what it is.
Four-way dispatch in JSX:
import { Data, Match } from 'effect'
type Status = Data.TaggedEnum<{
Idle: {}
Loading: {}
Failed: { error: string }
Loaded: { greeting: string }
}>
const Status = Data.taggedEnum<Status>()
function Greeting({ status }: { status: Status }) {
return (
<div>
{Match.value(status).pipe(
Match.tagsExhaustive({
Idle: () => null,
Loading: () => <p>Loading…</p>,
Failed: ({ error }) => <p>Sorry: {error}</p>,
Loaded: ({ greeting }) => <p>{greeting}</p>,
}),
)}
</div>
)
}The same dispatch in the DSL:
import { Match as M, Schema as S } from 'effect'
import { html } from 'foldkit/html'
const Idle = S.TaggedStruct('Idle', {})
const Loading = S.TaggedStruct('Loading', {})
const Failed = S.TaggedStruct('Failed', { error: S.String })
const Loaded = S.TaggedStruct('Loaded', { greeting: S.String })
const Status = S.Union(Idle, Loading, Failed, Loaded)
type Status = typeof Status.Type
const { div, p, empty } = html()
const greetingView = (status: Status) =>
div(
[],
[
M.value(status).pipe(
M.tagsExhaustive({
Idle: () => empty,
Loading: () => p([], ['Loading…']),
Failed: ({ error }) => p([], [`Sorry: ${error}`]),
Loaded: ({ greeting }) => p([], [greeting]),
}),
),
],
)With Data.TaggedEnum and Match from effect-ts, JSX gets dispatch parity. Both versions are exhaustive. Both fail to compile if you add a fifth variant without handling it. Without those tools, JSX is back to ternaries, &&, and extracted helper components, with exhaustiveness on you.
The remaining difference is structural. JSX is an expression syntax, so the match lives inside {...} braces inside a wrapping element, and every arm returns a React node. The DSL returns Html directly into the children array because the tree already is an array of values. No wrapping required. No expression embedding. The dispatch sits at the same level as everything else in the view.