On this pageA Simple Counter Example
Architecture & Concepts
The easiest way to learn how Foldkit works is to first look at examples, then dive deeper to understand each piece in isolation.
Here's a simple counter application that demonstrates Foldkit's core concepts: the Model (application state), Messages (model updates), Update (state transitions), and View (rendering). Take a look at the counter example below in full, then continue to see a more detailed explanation of each piece.
import { Match as M, Schema } from 'effect'
import { Runtime } from 'foldkit'
import { Command } from 'foldkit/command'
import { m } from 'foldkit/message'
import { Class, Html, OnClick, button, div } from '../html'
// MODEL - The shape of your application state
// In this case, our state is just a number representing the count
const Model = Schema.Number
type Model = typeof Model.Type
// MESSAGE - All possible events that can happen in your application
// Messages are dispatched from the view and handled by the update function
const ClickedDecrement = m('ClickedDecrement')
const ClickedIncrement = m('ClickedIncrement')
const ClickedReset = m('ClickedReset')
const Message = Schema.Union(
ClickedDecrement,
ClickedIncrement,
ClickedReset,
)
type Message = typeof Message.Type
// UPDATE - How your state changes in response to messages
// Returns a tuple of [nextModel, commands]
// Commands are side effects like HTTP requests (none needed here)
const update = (
count: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
M.tagsExhaustive({
ClickedDecrement: () => [count - 1, []],
ClickedIncrement: () => [count + 1, []],
ClickedReset: () => [0, []],
}),
)
// INIT - The initial model and any commands to run on startup
// Returns a tuple of [initialModel, initialCommands]
const init: Runtime.ElementInit<Model, Message> = () => [0, []]
// VIEW - Renders your state as HTML
// Pure function: same state always produces the same HTML - no side effects in
// the view
const view = (count: Model): Html =>
div(
[Class(containerStyle)],
[
div(
[Class('text-6xl font-bold text-gray-800')],
[count.toString()],
),
div(
[Class('flex flex-wrap justify-center gap-4')],
[
button(
[OnClick(ClickedDecrement()), Class(buttonStyle)],
['-'],
),
button(
[OnClick(ClickedReset()), Class(buttonStyle)],
['Reset'],
),
button(
[OnClick(ClickedIncrement()), Class(buttonStyle)],
['+'],
),
],
),
],
)
// STYLE
const containerStyle =
'min-h-screen bg-white flex flex-col items-center justify-center gap-6 p-6'
const buttonStyle =
'bg-black text-white hover:bg-gray-700 px-4 py-2 transition'
// RUN - Wire everything together and start the application
const element = Runtime.makeElement({
Model,
init,
update,
view,
container: document.getElementById('root')!,
})
Runtime.run(element)The Model represents your entire application state in a single, immutable data structure. In Foldkit, the Model is defined using Effect Schema, which provides runtime validation, type inference, and a single source of truth for your application state.
In the counter example, the model is simply a number.
import { Schema } from 'effect'
// MODEL - The shape of your application state
// In this case, our state is just a number representing the count
const Model = Schema.Number
type Model = typeof Model.TypeFor React developers
Think of the Model as combining useState, useContext, and your Redux store into one typed structure. Instead of state scattered across components, everything lives here.
Messages represent all the events that can occur in your application. They describe what happened, not how to handle it. Messages are implemented as tagged unions, providing exhaustive pattern matching and type safety.
The counter example has three simple messages:
import { Schema } from 'effect'
import { m } from 'foldkit/message'
// MESSAGE - All possible events that can happen in your application
// Messages are dispatched from the view and handled by the update function
// m wraps Schema.TaggedStruct with a callable constructor — write Foo() instead of Foo.make()
const ClickedDecrement = m('ClickedDecrement')
const ClickedIncrement = m('ClickedIncrement')
const ClickedReset = m('ClickedReset')
const Message = Schema.Union(
ClickedDecrement,
ClickedIncrement,
ClickedReset,
)
type Message = typeof Message.TypeFor React developers
Messages are similar to Redux action types, but more ergonomic with Effect Schema. Instead of string constants and action creators, you get type inference and pattern matching for free.
The update function is the heart of your application logic. It's a pure function that takes the current model and a message, and returns a new model along with any commands to execute. Commands represent side effects and are covered later on this page.
Foldkit uses Effect.Match for exhaustive pattern matching on messages. The TypeScript compiler will error if you forget to handle a message type.
import { Match as M } from 'effect'
import { Command } from 'foldkit/command'
// UPDATE - How your state changes in response to messages
// Returns a tuple of [nextModel, commands]
// Commands are side effects like HTTP requests (none needed here)
const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
M.tagsExhaustive({
// This means: the next model (application state) is
// model - 1 and there are no commands to run
ClickedDecrement: () => [model - 1, []],
ClickedIncrement: () => [model + 1, []],
ClickedReset: () => [0, []],
}),
)For React developers
Like a Redux reducer, but update returns Commands instead of triggering useEffect. You'll never wonder why an effect ran — it's explicit in the return value.
The view function is a pure function that transforms your model into HTML. Given the same model, it always produces the same HTML output. The view never directly modifies state — instead, it dispatches messages through event handlers.
import { Class, Html, OnClick, button, div } from '../html'
// VIEW - Renders your state as HTML
// Pure function: same state always produces the same HTML — no side effects
const view = (model: Model): Html =>
div(
[Class(containerStyle)],
[
div(
[Class('text-6xl font-bold text-gray-800')],
[model.toString()],
),
div(
[Class('flex flex-wrap justify-center gap-4')],
[
// OnClick takes a Message, not a callback. The Message doesn't
// execute anything — it just declares what should happen on click.
// Foldkit dispatches it to your update function.
button([OnClick(Decrement()), Class(buttonStyle)], ['-']),
button([OnClick(Reset()), Class(buttonStyle)], ['Reset']),
button([OnClick(Increment()), Class(buttonStyle)], ['+']),
],
),
],
)
// STYLE
const containerStyle =
'min-h-screen bg-white flex flex-col items-center justify-center gap-6 p-6'
const buttonStyle =
'bg-black text-white hover:bg-gray-700 px-4 py-2 transition'For React developers
The view is like a functional component, but guaranteed pure — no hooks, no effects, no local state. It's a function from Model to Html. This simplicity means no "rules of hooks" to follow.
Foldkit's HTML functions are typed to your Message type. This ensures event handlers only accept valid Messages from your application. You create these helpers by calling html<Message>() and destructuring the elements and attributes you need:
import { html } from 'foldkit/html'
// Create typed HTML helpers for your Message type.
// This ensures event handlers like OnClick only accept your Message variants.
export const {
// Attributes
Class,
Id,
Href,
OnClick,
OnInput,
OnSubmit,
Value,
// Elements
a,
button,
div,
form,
h1,
input,
p,
span,
} = html<Message>()This pattern might seem unusual if you're coming from React, but it provides strong type safety. If you try to pass an invalid Message to OnClick, TypeScript will catch it at compile time. You only need to do this once per module — most apps create a single html.ts file and import from there.
Event handlers in Foldkit work differently from React. Instead of passing a callback function, you pass a Message. When the event fires, Foldkit dispatches that Message to your update function.
// Event handlers take Messages, not callbacks.
// When the button is clicked, Foldkit dispatches the Message
// to your update function.
button(
[OnClick(ClickedIncrement()), Class('button-primary')],
['Click me'],
)
// For input events, Foldkit extracts the value and passes it
// to your function:
input([
OnInput(value => ChangedSearch({ text: value })),
Value(model.searchText),
Class('input'),
])For simple events like clicks, you pass the Message directly. For events that carry data (like input changes), you pass a function that receives the event and returns a Message. This keeps your view declarative — it describes what Messages should be sent, not how to handle them.
You're probably wondering how to handle side effects like HTTP requests, timers, or interacting with the browser API. In Foldkit, side effects are managed through commands returned by the update function. This keeps your update logic pure and testable.
Let's start simple. Say we want to wait one second before resetting the count if the user clicks reset. This is how we might implement that:
import { Effect, Match as M } from 'effect'
import { Task } from 'foldkit'
import { Command } from 'foldkit/command'
const ClickedResetAfterDelay = m('ClickedResetAfterDelay')
const ElapsedResetDelay = m('ElapsedResetDelay')
const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
M.tagsExhaustive({
ClickedResetAfterDelay: () => [
model,
[Task.delay('1 second').pipe(Effect.as(ElapsedResetDelay()))],
],
ElapsedResetDelay: () => [0, []],
}),
)Now, what if we want to get the next count from an API instead of incrementing locally? We can create a Command that performs the HTTP request and returns a Message when it completes:
import { Effect, Match as M, Schema } from 'effect'
import { Command } from 'foldkit/command'
import { m } from 'foldkit/message'
const ClickedFetchCount = m('ClickedFetchCount')
const SucceededCountFetch = m('SucceededCountFetch', {
count: Schema.Number,
})
const FailedCountFetch = m('FailedCountFetch', {
error: Schema.String,
})
const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
M.tagsExhaustive({
// Tell Foldkit to fetch the count from the API
ClickedFetchCount: () => [model, [fetchCount]],
// Update the count on successful API response
SucceededCountFetch: ({ count }) => [count, []],
// Keep the current count on failure
FailedCountFetch: ({ error }) => {
// We could also update our model to include the error message
// and display it in the view.
return [model, []]
},
}),
)
// Command that fetches the count from an API
const fetchCount: Command<
typeof SucceededCountFetch | typeof FailedCountFetch
> = Effect.gen(function* () {
const result = yield* Effect.tryPromise(() =>
fetch('/api/count').then(res => {
if (!res.ok) throw new Error('API request failed')
return res.json() as unknown as { count: number }
}),
)
return SucceededCountFetch({ count: result.count })
}).pipe(
Effect.catchAll(error =>
Effect.succeed(FailedCountFetch({ error: error.message })),
),
)Let's zoom in on fetchCount to understand what's happening here:
import { Effect } from 'effect'
import { Command } from 'foldkit/command'
const fetchCount: Command<
typeof SucceededCountFetch | typeof FailedCountFetch
> = Effect.gen(function* () {
// tryPromise creates an Effect that represents an asynchronous computation
// that might fail. If the Promise rejects, it is propagated to the error channel
// in the Effect as UnknownException.
// https://effect.website/docs/getting-started/creating-effects/#trypromise
const result = yield* Effect.tryPromise(() =>
fetch('/api/count').then(res => {
if (!res.ok) throw new Error('API request failed')
// NOTE: We would not cast in a real application. Instead, we would
// decode the JSON using Effect Schema. For simplicity, we skip that here.
return res.json() as unknown as { count: number }
}),
)
// If we reach this, the Effect above that uses tryPromise succeeded,
// and we can return the SucceededCountFetch message
return SucceededCountFetch({ count: result.count })
}).pipe(
// We are forced by the type system to handle the error case because
// Command's may not fail. They must always return a Message. Here, we recover
// from failure by returning a FailedCountFetch Message with the error message.
// In a real application, we might log the error to an external service,
// retry the request, etc.
Effect.catchAll(error =>
Effect.succeed(FailedCountFetch({ error: error.message })),
),
)Commands are great for one-off side effects, but what about ongoing streams of events? Think timers, WebSocket connections, or keyboard input. For these, Foldkit provides Subscriptions.
A Subscription is a reactive binding between your model and a long-running stream. You declare which part of the model the subscription depends on, and Foldkit manages the stream lifecycle automatically, starting it when the component mounts and restarting it whenever those dependencies change.
Let's look at a stopwatch example. We want a timer that ticks every 100ms, but only when isRunning is true. This gives us a way to start and stop the stopwatch based on user input.
import { Duration, Effect, Schema as S, Stream } from 'effect'
import { Subscription } from 'foldkit'
import { m } from 'foldkit/message'
// MESSAGE
const Ticked = m('Ticked')
const Message = S.Union(Ticked)
type Message = typeof Message.Type
// MODEL
const Model = S.Struct({
isRunning: S.Boolean,
elapsed: S.Number,
})
type Model = typeof Model.Type
// SUBSCRIPTION
const SubscriptionDeps = S.Struct({
tick: S.Struct({
isRunning: S.Boolean,
}),
})
const subscriptions = Subscription.makeSubscriptions(
SubscriptionDeps,
)<Model, Message>({
tick: {
modelToDependencies: model => ({ isRunning: model.isRunning }),
depsToStream: ({ isRunning }) =>
Stream.when(
Stream.tick(Duration.millis(100)).pipe(
Stream.map(() => Effect.succeed(Ticked())),
),
() => isRunning,
),
},
})The key concept is SubscriptionDeps. This schema defines what parts of the model your subscriptions depend on. Each subscription has two functions:
modelToDependencies extracts the relevant dependencies from the model.
depsToStream creates a stream based on those dependencies.
Foldkit structurally compares the dependencies between updates. The stream is only restarted when the dependencies actually change, not on every model update.
When isRunning changes from false to true, the stream starts ticking. When it changes back to false, the stream stops. Foldkit handles all the lifecycle management for you.
For a more complex example using WebSocket connections, see the websocket-chat example. For a full real-world application, check out Typing Terminal (source).
If you're coming from Elm, Subscriptions in Foldkit produce Command<Message> rather than plain Message. This means each item in the stream can do async work before resolving to a message, avoiding extra round-trips through update.
The init function returns the initial model and any commands to run on startup. It returns a tuple of [Model, ReadonlyArray<Command<Message>>].
import { Schema as S } from 'effect'
import type { Runtime } from 'foldkit'
import { m } from 'foldkit/message'
const Model = S.Number
type Model = typeof Model.Type
const ClickedIncrement = m('ClickedIncrement')
const ClickedDecrement = m('ClickedDecrement')
const Message = S.Union(ClickedIncrement, ClickedDecrement)
type Message = typeof Message.Type
const init: Runtime.ElementInit<Model, Message> = () => [0, []]For elements (components without routing), init takes no arguments. For applications with routing, init receives the current URL so you can set up initial state based on the route.
Flags let you pass initialization data into your application — things like persisted state from localStorage or configuration values. Define a Flags schema and provide an Effect that loads the flags.
import { KeyValueStore } from '@effect/platform'
import { BrowserKeyValueStore } from '@effect/platform-browser'
import { Effect, Option, Schema as S } from 'effect'
const Todo = S.Struct({
id: S.String,
text: S.String,
completed: S.Boolean,
})
const Todos = S.Array(Todo)
const Flags = S.Struct({
todos: S.Option(Todos),
})
type Flags = typeof Flags.Type
const flags: Effect.Effect<Flags> = Effect.gen(function* () {
const store = yield* KeyValueStore.KeyValueStore
const maybeTodosJson = yield* store.get('todos')
const todosJson = yield* maybeTodosJson
const decodeTodos = S.decode(S.parseJson(Todos))
const todos = yield* decodeTodos(todosJson)
return { todos: Option.some(todos) }
}).pipe(
Effect.catchAll(() => Effect.succeed({ todos: Option.none() })),
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
)When using flags, your init function receives them as the first argument:
import { Option, Schema as S } from 'effect'
import type { Runtime } from 'foldkit'
import { m } from 'foldkit/message'
const Model = S.Struct({
count: S.Number,
startingCount: S.Option(S.Number),
})
type Model = typeof Model.Type
const Flags = S.Struct({
savedCount: S.Option(S.Number),
})
type Flags = typeof Flags.Type
const ClickedIncrement = m('ClickedIncrement')
const Message = S.Union(ClickedIncrement)
type Message = typeof Message.Type
const init: Runtime.ElementInit<Model, Message, Flags> = flags => [
{
count: Option.getOrElse(flags.savedCount, () => 0),
startingCount: flags.savedCount,
},
[],
]Foldkit provides utility functions for common side effects that return commands you can use in your update function.
Task.getTime gets the current UTC time. Task.getZonedTime gets time with the system timezone. Task.getZonedTimeIn gets time in a specific timezone.
import { Effect } from 'effect'
import { Task } from 'foldkit'
const getTime = Task.getTime.pipe(Effect.map(utc => GotTime({ utc })))
const getZonedTime = Task.getZonedTime.pipe(
Effect.map(zoned => GotZonedTime({ zoned })),
)
const getNyTime = Task.getZonedTimeIn('America/New_York').pipe(
Effect.map(zoned => GotNyTime({ zoned })),
Effect.catchAll(() => Effect.succeed(FailedTimeZone())),
)Task.focus focuses an element by CSS selector (useful after form submission). Task.randomInt generates random integers.
import { Effect } from 'effect'
import { Task } from 'foldkit'
// Focus an element after form submission
const focusEmailInput = Task.focus('#email-input').pipe(
Effect.ignore,
Effect.as(Focused()),
)
// Generate a random integer between 1 and 6 (dice roll)
const rollDice = Task.randomInt(1, 7).pipe(
Effect.map(value => GotDiceRoll({ value })),
)To run a Foldkit application, create a runtime with makeElement or makeApplication, then call Runtime.run.
makeElement creates a component without URL handling. The init function takes no URL argument (or just flags if you use them). Use this for standalone widgets or components embedded in existing pages.
import { Runtime } from 'foldkit'
const element = Runtime.makeElement({
Model,
init,
update,
view,
container: document.getElementById('root')!,
})
Runtime.run(element)makeApplication creates a full-page application with routing. The init function receives the current URL, and you must provide a browser config to handle URL changes.
import { Runtime } from 'foldkit'
const app = Runtime.makeApplication({
Model,
init, // receives (url: Url) => [Model, Commands]
update,
view,
container: document.getElementById('root')!,
browser: {
onUrlRequest: request => ClickedLink({ request }),
onUrlChange: url => ChangedUrl({ url }),
},
})
Runtime.run(app)The browser config has two handlers: onUrlRequest is called when a link is clicked (giving you a chance to handle internal vs external links), and onUrlChange is called when the URL changes (so you can update your model with the new route).
Commands are self-contained by default — each execution starts fresh with no shared state. But some browser APIs like AudioContext, RTCPeerConnection, or CanvasRenderingContext2D need a single long-lived instance shared across commands. That’s what resources is for.
Define a service using Effect.Service, then pass its default layer to makeElement or makeApplication via the resources config field. The runtime memoizes the layer once and provides it to every command and subscription automatically.
import { Effect } from 'effect'
import { Runtime } from 'foldkit'
import { Command } from 'foldkit/command'
// 1. Define a service using Effect.Service
class AudioContextService extends Effect.Service<AudioContextService>()(
'AudioContextService',
{ sync: () => new AudioContext() },
) {}
// 2. Commands yield the service — provided via the resources layer
const playNote = (
frequency: number,
duration: number,
): Command<typeof PlayedNote, never, AudioContextService> =>
Effect.gen(function* () {
const audioContext = yield* AudioContextService
const oscillator = audioContext.createOscillator()
oscillator.frequency.setValueAtTime(
frequency,
audioContext.currentTime,
)
oscillator.connect(audioContext.destination)
oscillator.start()
oscillator.stop(audioContext.currentTime + duration)
return PlayedNote()
})
// 3. Pass the service's default layer to makeElement
const element = Runtime.makeElement({
Model,
init,
update,
view,
container: document.getElementById('root')!,
resources: AudioContextService.Default,
})Commands declare their resource requirements in the type signature via the third type parameter of Command. This makes dependencies explicit and type-checked — if a command requires a service that isn’t provided via resources, you’ll get a compile error.
When not to use resources
Resources are for mutable browser singletons with lifecycle — things that must be created once and reused. Stateless services like HttpClient or BrowserKeyValueStore should be provided per-command with Effect.provide instead.
The resources field takes a single Layer, but Effect layers compose. Use Layer.mergeAll to combine multiple service layers into one.
import { Layer } from 'effect'
import { Runtime } from 'foldkit'
const element = Runtime.makeElement({
Model,
init,
update,
view,
container: document.getElementById('root')!,
resources: Layer.mergeAll(
AudioContextService.Default,
WebRTCService.Default,
CanvasService.Default,
),
})Resources live for the entire application lifecycle. But some resources are heavy and should only be active while the model is in a particular state — a camera stream during a video call, a WebSocket connection while on a chat page, or a Web Worker pool during a computation. Managed resources provide model-driven acquire/release lifecycle, using the same deps-diffing engine as subscriptions.
Define a managed resource identity with ManagedResource.tag, then wire its lifecycle with makeManagedResources. The modelToMaybeRequirements function returns Option.some(params) when the resource should be active, and Option.none() when it should be released.
import { Effect, Option, Schema as S, pipe } from 'effect'
import { ManagedResource, Runtime } from 'foldkit'
// 1. Define a managed resource identity
const CameraStream =
ManagedResource.tag<MediaStream>()('CameraStream')
// 2. Define a requirements schema — Option.some = active, Option.none = inactive
const ManagedResourceDeps = S.Struct({
camera: S.Option(S.Struct({ facingMode: S.String })),
})
// 3. Wire lifecycle with makeManagedResources
const managedResources = ManagedResource.makeManagedResources(
ManagedResourceDeps,
)<Model, Message>({
camera: {
resource: CameraStream,
modelToMaybeRequirements: model =>
pipe(
model.callState,
Option.liftPredicate(
(callState): callState is typeof InCall.Type =>
callState._tag === 'InCall',
),
Option.map(callState => ({
facingMode: callState.facingMode,
})),
),
acquire: ({ facingMode }) =>
Effect.tryPromise(() =>
navigator.mediaDevices.getUserMedia({
video: { facingMode },
}),
),
release: stream =>
Effect.sync(() =>
stream.getTracks().forEach(track => track.stop()),
),
onAcquired: () => AcquiredCamera(),
onReleased: () => ReleasedCamera(),
onAcquireError: error =>
FailedToAcquireCamera({ error: String(error) }),
},
})
// 4. Pass to makeElement or makeApplication
const element = Runtime.makeElement({
Model,
init,
update,
view,
container: document.getElementById('root')!,
managedResources,
})When requirements change, the runtime handles the lifecycle automatically. If modelToMaybeRequirements transitions from Option.none() to Option.some(params), the resource is acquired and onAcquired is sent. When it goes back to Option.none(), the resource is released and onReleased is sent. If the params change while active (e.g. switching cameras), the old resource is released and a new one is acquired with the new params.
If acquisition fails, onAcquireError is sent as a message. The resource daemon continues watching for the next deps change — a failed acquisition does not crash the application.
Commands access the resource value via .get. Since the resource might not be active, .get can fail with ResourceNotAvailable. The type system enforces this — your command won’t compile unless you handle the error.
import { Array, Effect, Option } from 'effect'
import { ManagedResource } from 'foldkit'
import { Command } from 'foldkit/command'
const CameraStream =
ManagedResource.tag<MediaStream>()('CameraStream')
// .get carries the resource identity in the R channel,
// so TypeScript verifies the resource is registered at compile time
const takePhoto = (): Command<
typeof TookPhoto | typeof CameraUnavailable,
never,
ManagedResource.ServiceOf<typeof CameraStream>
> =>
Effect.gen(function* () {
const stream = yield* CameraStream.get
const maybeTrack = Array.head(stream.getVideoTracks())
const bitmap = yield* Option.match(maybeTrack, {
onNone: () =>
Effect.fail(new Error('No video track available')),
onSome: track => {
const imageCapture = new ImageCapture(track)
return Effect.promise(() => imageCapture.grabFrame())
},
})
return TookPhoto({ width: bitmap.width, height: bitmap.height })
}).pipe(
Effect.catchTag('ResourceNotAvailable', () =>
Effect.succeed(CameraUnavailable()),
),
)This is the same catchTag pattern you already use for command errors. If your model correctly gates commands (only dispatching takePhoto after AcquiredCamera has been received), the catchTag is a safety net that never fires. But if your model logic has a bug, you get a graceful error message instead of a crash.
Resources vs Managed Resources
Use resources for things that live forever (AudioContext, CanvasRenderingContext2D). Use managedResources for things tied to a model state (camera streams, WebSocket connections, media recorders).
When Foldkit hits an unrecoverable error during update, view, or command execution, it stops all processing and renders a fallback UI. This is not error handling — there is no recovery from this state. The runtime is dead.
By default, Foldkit shows a built-in error screen with the error message and a reload button. Pass an errorView function to makeElement or makeApplication to customize it. It receives the Error and returns Html:
import { Runtime } from 'foldkit'
import { Html, html } from 'foldkit/html'
const errorView = (error: Error): Html => {
const { div, h1, p, button, Class, Attribute } = html<never>()
return div(
[
Class(
'min-h-screen flex items-center justify-center bg-red-50 p-8',
),
],
[
div(
[
Class(
'max-w-md w-full bg-white rounded-lg border border-red-200 p-8 text-center',
),
],
[
h1(
[Class('text-red-600 text-2xl font-semibold mb-4')],
['Something went wrong'],
),
p([Class('text-gray-700 mb-6')], [error.message]),
button(
[
Class(
'bg-red-600 text-white px-6 py-2.5 rounded-md text-sm font-medium cursor-pointer',
),
Attribute('onclick', 'location.reload()'),
],
['Reload'],
),
],
),
],
)
}
const element = Runtime.makeElement({
Model,
init,
update,
view,
errorView,
container: document.getElementById('root')!,
})
Runtime.run(element)Call html<never>() with never as the type parameter. Since the runtime has stopped, no messages will ever be dispatched — never makes this explicit and prevents event handlers like OnClick from being used.
Foldkit’s event handlers like OnClick work by dispatching messages to the runtime. Since the runtime has stopped, those handlers are silently ignored. For interactivity, like a reload button, use Attribute('onclick', 'location.reload()'). This sets a raw DOM event handler directly on the element, bypassing Foldkit’s dispatch system entirely.
Only in errorView
In a normal Foldkit app, always use OnClick with messages — never raw DOM event attributes. errorView is the one exception because the runtime is no longer running.
If your custom errorView itself throws an error, Foldkit catches it and falls back to the default error screen showing both the original error and the errorView error.
See the error-view example for a working demonstration.
During development, Foldkit warns in the console when a view call takes longer than the frame budget. A view that exceeds 16ms is already dropping frames. The warning nudges you to move computation into update or memoize expensive subtrees with createLazy and createKeyedLazy.
The warning only runs in dev mode (gated behind import.meta.hot), so there is zero runtime cost in production builds.
The default threshold is 16ms (one frame at 60fps). Pass slowViewThresholdMs to makeElement or makeApplication to customize it:
import { Runtime } from 'foldkit'
const element = Runtime.makeElement({
Model,
init,
update,
view,
container: document.getElementById('root')!,
slowViewThresholdMs: 50,
})
Runtime.run(element)Set slowViewThresholdMs to false to disable the warning entirely.