# Foldkit Documentation Generated 2026-08-26 from https://foldkit.dev Foldkit is a TypeScript frontend framework built on Effect-TS that uses The Elm Architecture: a single Model, pure update, and side effects confined to Commands. --- Source: https://foldkit.dev/ Beta # The frontend framework for correctness. Bring Effect’s explicitness to your frontend. Foldkit gives your entire application one architecture with an idiomatic place for every behavior. ``` npx create-foldkit-app@latest ``` [Learn the architecture](https://foldkit.dev/core/architecture) [GitHub](https://github.com/foldkit/foldkit) [X](https://x.com/devinjameson) [Discord](https://discord.gg/kav8VNxqGm) [npm](https://www.npmjs.com/package/foldkit) ## See it work. Watch a message flow through update into the model. The code highlights in real time to show you what’s happening at each step. ``` import { Effect, Schema as S } from 'effect' import { Command, Update } from 'foldkit' import { defineMessageUnion } from 'foldkit/message' import { evo } from 'foldkit/struct' // MODEL const Model = S.Struct({ count: S.Number, isResetting: S.Boolean, resetDuration: S.Number, }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ ClickedIncrement: {}, ChangedResetDuration: { seconds: S.Number }, ClickedResetAfterDelay: {}, CompletedDelayReset: {}, }) type Message = typeof Message.Type // COMMAND const DelayReset = Command.define('DelayReset', { args: { seconds: S.Number }, messages: [Message.CompletedDelayReset], execute: ({ seconds }) => Effect.as( Effect.sleep(`${seconds} seconds`), Message.CompletedDelayReset(), ), }) // UPDATE type UpdateReturn = Update.Return const update = (model: Model, message: Message) => Message.match(message, { ClickedIncrement: () => ({ model: evo(model, { count: count => count + 1 }), }), ChangedResetDuration: ({ seconds }) => ({ model: evo(model, { resetDuration: () => seconds }), }), ClickedResetAfterDelay: () => ({ model: evo(model, { isResetting: () => true }), commands: [DelayReset({ seconds: model.resetDuration })], }), CompletedDelayReset: () => ({ model: evo(model, { count: () => 0, isResetting: () => false }), }), }) ``` 0 Reset Delay (seconds) 2 Model State count : 0 isResetting : false resetDuration : 2 Phase Idle Message Log ## Declare behavior. Ship. Repeat. React, Vue, Svelte, and Solid solve rendering and leave the architecture to you. Foldkit gives you the architecture, so you can focus on your domain. ### Predictable state One immutable model holds your entire application state. Every change flows through a single update function. No hidden mutations, no stale closures, no surprises. ### Explicit effects Side effects are values you return from update, not imperative calls buried in handlers. Commands describe what should happen. The runtime handles when and how. ### Shared structure A 50-file application uses the same Model, Message, update, and Command structure as a 5-file application. New work has a known place, and reviews start from shared conventions. ## Built on [Effect](https://effect.website). Inside and out. If your backend already uses Effect, Foldkit carries the same tools and patterns into the browser. If Effect is new to your team, it is part of the learning curve. - Every Foldkit application is an Effect - The entire Model is defined by Schema - Commands use Effect for services, interruption, resources, and concurrency ## Architectural fit. Foldkit uses [The Elm Architecture](https://guide.elm-lang.org/architecture/). Application state does not live in component instances or hook lifecycles. The Model is the single source of truth, and every transition stays visible in update. That discipline is a real commitment. Foldkit works best when the team wants one architecture across the application and is ready to build on Effect throughout. ### A strong fit - ### Effect developers who need a frontend Your backend already uses Effect. Foldkit carries Schema, services, Streams, and scoped resources into frontend architecture. - ### Applications with complex state Auth flows, real-time data, and multi-step forms become explicit states and transitions instead of effects and refs spread across the tree. - ### Teams that want shared conventions One pattern for state, effects, and views gives features a known shape and reviews a common vocabulary. ### Think twice when - ### Large existing React codebases Foldkit isn’t an incremental adoption. It’s a different architecture, and migrating means a rewrite. The middle path is embedding: Runtime.embed runs a Foldkit widget inside an existing app. - ### Projects that need the React ecosystem The application depends on React component libraries, Next.js, or middleware built for that stack. Foldkit uses different foundations. - ### Sites that are mostly static content A site that is mostly prose with a sprinkle of interactivity is better served by a content-first tool like Astro. Foldkit renders on the server too, but it is built for applications. [Compare with React](https://foldkit.dev/react/coming-from-react) [Compare with React + Effect Atom](https://foldkit.dev/react/foldkit-vs-react-effect-atom) ## Project status. Foldkit is in beta and under active development. The links below show the current version and what is ready to use today. - Version v0.152.0 - Example apps [34](https://foldkit.dev/example-apps) - Production app [Typing Terminal](https://foldkit.dev/example-apps/typing-terminal) - Changelog [View releases](https://github.com/foldkit/foldkit/blob/main/packages/foldkit/CHANGELOG.md) ## Batteries included. Routing, server rendering, UI components, composition, and browser lifecycles all use the same Model and Message flow. ### Routing Type-safe bidirectional routing. URLs parse into typed routes and routes build back into URLs. No string matching, no mismatches between parsing and building. [Explore routing](https://foldkit.dev/core/routing-and-navigation) ### Server Rendering One rendering pipeline: generate static HTML during the build, or render each request on a server. The same init, view, and Model run on both sides, and the browser hydrates the served HTML in place. [Explore server rendering](https://foldkit.dev/core/server-rendering) ### UI Components Accessible dialogs, menus, tabs, listboxes, disclosures, and more. Each stateful component follows The Elm Architecture and stays open to styling and composition. [Browse the components](https://foldkit.dev/ui/overview) ### Submodels A self-contained Model, Messages, update, and view, embedded inside a larger program. Children surface domain facts as typed OutMessages and parents handle them in update. Every stateful Foldkit UI component ships as a Submodel. [Explore Submodels](https://foldkit.dev/core/submodel) ### Browser Lifecycles Subscriptions open scoped event streams while a Model condition holds. Managed Resources acquire stateful handles like WebSockets and AudioContext. The runtime closes both when the Model no longer needs them. [Explore browser lifecycles](https://foldkit.dev/core/managed-resources) ### Embedding Run a Foldkit widget inside any host application with Runtime.embed. The host pushes data in and receives values out through Schema-typed Ports, and tears the widget down with dispose. [Explore embedding](https://foldkit.dev/core/embedding) ## Example applications. One architecture, many kinds of software. Open any example to run it, see how it is modeled, and read the source. - [Generative Art](https://foldkit.dev/example-apps/generative-art) - [State Machine](https://foldkit.dev/example-apps/state-machine) - [Pixel Art](https://foldkit.dev/example-apps/pixel-art) - [WebSocket Chat](https://foldkit.dev/example-apps/websocket-chat) - [Kanban](https://foldkit.dev/example-apps/kanban) - [Map](https://foldkit.dev/example-apps/map) - [Snake](https://foldkit.dev/example-apps/snake) - [UI Showcase](https://foldkit.dev/example-apps/ui-showcase) - [Job Application](https://foldkit.dev/example-apps/job-application) - [Charting](https://foldkit.dev/example-apps/charting) - [Counter](https://foldkit.dev/example-apps/counter) - [Counters](https://foldkit.dev/example-apps/counters) - [Todo](https://foldkit.dev/example-apps/todo) - [Stopwatch](https://foldkit.dev/example-apps/stopwatch) - [Crash View](https://foldkit.dev/example-apps/crash-view) - [Slow Warnings](https://foldkit.dev/example-apps/slow-warnings) - [Form](https://foldkit.dev/example-apps/form) - [Weather](https://foldkit.dev/example-apps/weather) - [API Cache](https://foldkit.dev/example-apps/api-cache) - [Routing](https://foldkit.dev/example-apps/routing) - [Route Transitions](https://foldkit.dev/example-apps/route-transitions) - [Interrupting Commands](https://foldkit.dev/example-apps/interrupting-commands) - [View Transitions](https://foldkit.dev/example-apps/view-transitions) - [Query Sync](https://foldkit.dev/example-apps/query-sync) - [Auth](https://foldkit.dev/example-apps/auth) - [Shopping Cart](https://foldkit.dev/example-apps/shopping-cart) - [Managed Resource Layer](https://foldkit.dev/example-apps/managed-resource-layer) - [Canvas Art](https://foldkit.dev/example-apps/canvas-art) - [Web Components](https://foldkit.dev/example-apps/web-components) - [Embedding](https://foldkit.dev/example-apps/embedding) - [Static Site Generation](https://foldkit.dev/example-apps/ssg) - [Server-Side Rendering](https://foldkit.dev/example-apps/ssr) - [Personal Blog](https://foldkit.dev/example-apps/personal-blog) - [Typing Terminal](https://foldkit.dev/example-apps/typing-terminal) - [Explore the catalog](https://foldkit.dev/example-apps) ## Tests that read like stories and scenes. Pure update functions mean pure tests. Story tests the state machine. Scene tests features through the view (clicking buttons, typing into inputs) with accessible locators. No DOM, no mocking. [Learn about testing](https://foldkit.dev/testing) ``` import { Command, given, message, model, story } from 'foldkit/story' import { expect, test } from 'vitest' // Story: test the state machine test('fetch weather updates the model', () => { story( update, given(model), message(SubmittedWeatherForm()), model(model => { expect(model.weather._tag).toBe('WeatherLoading') }), Command.expectExact(FetchWeather), Command.resolve(FetchWeather, SucceededFetchWeather({ weather })), model(model => { expect(model.weather._tag).toBe('WeatherSuccess') }), ) }) ``` ``` import { Command, click, expect, given, inside, label, role, scene, text, type, } from 'foldkit/scene' import { test } from 'vitest' // Scene: test through the view test('type a zip code, click get weather, see the forecast', () => { scene( { update, view }, given(model), type(label('Zip code'), '90210'), click(role('button', { name: 'Get Weather' })), expect(role('button', { name: 'Loading...' })).toExist(), Command.expectExact(FetchWeather), Command.resolve(FetchWeather, SucceededFetchWeather({ weather })), inside( role('article'), expect(text('Beverly Hills, California')).toExist(), expect(text('72°F')).toExist(), ), ) }) ``` ## Watch your program think. When every state change flows through Messages and one Model, DevTools can show the full history of the program. Every Message is logged. Every Model state is inspectable. Select any row to see what changed, then rewind the UI to that state. The same runtime data is available to AI agents over MCP. They can inspect the current Model, walk Message history, rewind the UI to past states, and dispatch Messages. This site runs on Foldkit. Look for the tab on the bottom right of this page to try DevTools live. [Learn about DevTools](https://foldkit.dev/core/devtools) ## Built for b. Every feature has the same visible structure: a Schema-defined Model, fact-named Messages, exhaustive update, and explicit Commands. AI-generated changes follow code paths a person can inspect and test. AI agents can also connect directly to a running Foldkit app over the Model Context Protocol. They read the current Model, inspect Message history, rewind the UI to past states, and dispatch Messages. [Set up AI-assisted development](https://foldkit.dev/ai/overview) ## Start building. Scaffold an application, define the Model, and make the first state transition explicit. [Get started](https://foldkit.dev/get-started/getting-started) [View on GitHub](https://github.com/foldkit/foldkit) ## Stay in the update loop. New releases, patterns, and the occasional deep dive. --- Source: https://foldkit.dev/get-started/manifesto Section: Docs # Manifesto ## The Architecture Problem Most frontend frameworks solve rendering and leave the rest to you. Where does state live? How do side effects work? How are errors handled? Those are your problems. The framework doesn’t have an opinion. The consequence is predictable. Every team builds a custom architecture from whatever libraries and patterns seem right at the time. State scatters across hooks, contexts, stores, and URL params. Side effects hide in callbacks, middleware, and dependency arrays. Conventions vary project to project, and the accepted best practices shift every couple of years. This is why large, well-architected React codebases are vanishingly rare. Building and maintaining a large React application requires that one or two Staff-level people have a strong, bespoke architectural vision, make that vision clear to all contributors, and enforce alignment to it over years. This is not the norm. React isn’t bad. It’s great. It’s also insufficient. What if there were no decisions to make about how to structure your application? Not because someone took choice away, but because the right answer was the only answer. What if frontend architecture was solved? That’s Foldkit. ## Power Through Constraints Every Foldkit app has the same architecture. Not by convention. By design. State lives in the Model. Events are Messages. Every state change flows through update. Side effects are described as data and executed by the runtime: Commands for one-shot effects, Mount for imperative DOM work bound to a live element, Subscriptions for Streams gated by a slice of your Model, ManagedResources for stateful handles gated by a slice of your Model. These aren’t conventions. This is the only path. You can’t scatter state across components because there are no component-local state hooks. You can’t hide side effects in the view because the view is a pure function. This might sound limiting. It’s the opposite. When architectural decisions are off the table, development gets more interesting. The questions shift from implementation to behavior. Not “How should we manage side effects in this component?” but “How should this feature behave?” Not “What tool should we use for streams and how should we wire them up to our components?” but “What Model state does this Subscription depend on?” Your focus elevates from implementation to behavior: the stuff that actually makes your application unique. ## Readable by Design Any developer can walk into any Foldkit app and immediately know where to look. All state is in the Model. All events are Messages. All transitions are in update. All side effects are described as data and executed by the runtime. This isn’t a claim about team discipline or code review. It’s a structural guarantee. The architecture makes it impossible to organize your app in a way that is opaque to a new reader. No other TypeScript framework can make this claim. Most frameworks are readable if the team is disciplined. Foldkit apps are readable by construction. The same property that makes Foldkit apps legible to new developers makes them legible to AI. When patterns are predictable and explicit (one state tree, one update loop, typed everything), AI assistants produce reliable code without constant correction. This isn’t a feature designed for AI. It’s a natural consequence of the architecture. ## Build Your Product, Not Your Architecture Frontend development should be about solving domain problems, not architectural ones. Time spent debating state management, hand-selecting libraries for basic functionality, and carefully enforcing conventions is pure overhead. Now, you can just spin up a Foldkit application and start modeling behavior. So here’s the big claim: Foldkit models the frontend so you can model everything else. State, events, transitions, effects, streams, resource management. All accounted for. All connected. All typed. All in one place. Frontend architecture is solved. The only question left is, what are you going to create? See you on GitHub, Devin --- Source: https://foldkit.dev/get-started/getting-started Section: Docs # Getting Started Built on Effect. Architected like Elm. Written in TypeScript. Let’s get your first application running. ## Create a Project Before you begin, install Node.js 22.22.2 or newer and make sure the package manager you want to use is available. The [create-foldkit-app](https://github.com/foldkit/foldkit/tree/main/packages/create-foldkit-app) scaffolder is the recommended way to start. Run the scaffolder: ``` npx create-foldkit-app@latest ``` The CLI asks for a project name, a rendering mode, and a package manager. If you choose a browser-only SPA, it also asks which [example](https://foldkit.dev/example-apps) you want to start from. The other rendering modes create their own starter applications: - **SPA:** renders entirely in the browser. - **[Static generation](https://foldkit.dev/core/server-rendering#build-time-ssg):** prerenders routes to static HTML, then hydrates them in the browser. - **[Server rendering](https://foldkit.dev/core/server-rendering):** renders each request on a Node server, then hydrates it in the browser. The scaffolder creates the project and installs its dependencies. Move into the new directory, then start the development server with the package manager you selected: ``` cd your-project ``` - pnpm: `pnpm dev` - npm: `npm run dev` - Yarn: `yarn dev` - Bun: `bun dev` Vite prints a local URL when the server is ready. Open it in your browser, and your first Foldkit application is running. ## Find Your Way Around The exact files depend on the rendering mode and starter example. A small browser-only SPA such as Counter begins with these pieces: - `src/main.ts`: pure application definitions - `src/entry.ts`: runtime bootstrap referenced by `index.html` - `src/styles.css`: Tailwind CSS entry point - `index.html`: HTML entry point - `vite.config.ts`: Vite configuration with `@foldkit/vite-plugin` - `tsconfig.json`: TypeScript configuration - `.oxlintrc.json`: Oxlint configuration - `.prettierrc`: Prettier configuration - `AGENTS.md`: your instructions for AI coding assistants working on the project - `FOLDKIT.md`: Foldkit's own conventions for those assistants, [replaced from the current template when you upgrade Foldkit](https://foldkit.dev/ai/overview) In a small starter, `src/main.ts` holds the Model, Messages, update, init, and view. Larger examples move those definitions into focused modules as the application grows. For the Counter starter, `src/entry.ts` imports the application definitions and starts the runtime with `Runtime.makeApplication` and `Runtime.run`. Other starters may compose the application from several modules or start a different host, but they keep runtime startup separate from the pure definitions. That separation lets tests import the application without starting a runtime as a side effect. The generated project also includes `lint` and `format` scripts. Run them with your selected package manager. For example: `pnpm lint` and `pnpm format`. See [Oxlint Plugin](https://foldkit.dev/tooling/oxlint-plugin) for the Foldkit-specific rules. ## Add Foldkit to an Existing Project Skip this section if you used `create-foldkit-app`. Scaffolded projects already receive compatible package versions. Foldkit currently uses the Effect v4 release candidate and pins its `effect` peer dependency to an exact version: `effect@4.0.0-rc.112`. Stable Effect v3 does not satisfy that pin. Adding Foldkit to an Effect v3 project produces peer dependency conflicts. When Foldkit moves to a new release candidate, an existing project may need to upgrade Effect at the same time. Install Foldkit together with its pinned peer dependency: ``` npm install foldkit effect@4.0.0-rc.112 ``` `@effect/platform-browser` is a separate package pinned to the same version. Install it when you use `@foldkit/devtools`, which declares it as a peer dependency, or when you need Effect browser services such as `BrowserKeyValueStore` and `BrowserCrypto`: ``` npm install @effect/platform-browser@4.0.0-rc.112 ``` ## Where to Go Next - Read [Architecture](https://foldkit.dev/core/architecture) to understand the Model, Message, update, and view loop. - If you know React, use [Coming from React](https://foldkit.dev/react/coming-from-react) to map familiar ideas onto Foldkit. If you know Elm, compare the two in [Foldkit vs Elm: Side by Side](https://foldkit.dev/elm/foldkit-vs-elm-side-by-side). - For AI-assisted development, follow the setup in [AI](https://foldkit.dev/ai/overview). --- Source: https://foldkit.dev/roadmap Section: Docs # Roadmap ## Current Goal Foldkit is pre-1.0, and the current goal is a production-ready 1.0. Everything in flight serves that release. Larger directions wait until the core is stable. Day-to-day work is tracked at ticket granularity in a private tracker, where priorities can change without making this page stale. This page records the durable plan: the work that gates 1.0, features already available behind an experimental boundary, and directions that may follow. [GitHub issues](https://github.com/foldkit/foldkit/issues) are the public place for bugs and feature requests. ## The Path to 1.0 1.0 is a stability commitment, not a feature milestone. It means the public API is locked under semver, real applications have put the framework under pressure, and the claims in these docs have published evidence behind them. The remaining work is grouped into these blocks, roughly in order: - **Framework capability:** finish the surface that applications cannot be built without. This work is in progress. - **Framework quality:** close correctness gaps before downstream work depends on them. That includes coverage for newer primitives and consistent patterns across UI components and example applications. - **Real-world stress tests:** build example applications in the domains early adopters are likely to explore. Treat every framework gap they expose as a framework bug. - **Benchmarks:** rendering comparisons are already published on the [Performance](https://foldkit.dev/faq/performance) page. The remaining work is reproducible evidence for TypeScript compilation and Runtime throughput, held to the same standard. - **Audits and developer experience:** audit Foldkit UI against WCAG with real screen readers, add axe-core regression checks to CI, and improve Runtime and type-level errors when an API is misused. - **Documentation:** publish a page for every core concept, an accessibility section for every UI component, and an end-to-end tutorial that builds a non-trivial application. - **Release:** lock the public API, publish the semver commitment, empty the bug backlog, write the 0.x to 1.0 migration guide, and cut a burn-in release before tagging 1.0. ## Experimental Server Rendering Build-time static generation and per-request server rendering are available today behind `foldkit/experimental`. Both use `Server.renderToString` and the same explicit hydration handoff through `Runtime.hydrate`. The [build-time SSG](https://foldkit.dev/core/server-rendering#build-time-ssg) and [request-time SSR](https://foldkit.dev/core/server-rendering#request-time-ssr) sections cover each deployment model. The experimental boundary allows this surface to change before 1.0 without weakening the stability commitment for the core API. ## Directions After 1.0 These are areas for exploration, not commitments: - **Commands on the server:** a Command whose Effect runs on the server, allowing an application to reach a database or private service without a separate API layer. The client would still dispatch a Message and wait for the result. - **Rendering beyond the DOM:** the Elm Architecture does not depend on a browser. A terminal renderer is the first candidate for another target. - **Libraries outside core:** core remains the architecture: the Runtime, routing, and lifecycle primitives. Higher-level concerns are likely to ship as libraries around Foldkit, as Foldkit UI already does. ## Settled Boundaries Two decisions will survive 1.0. Foldkit will not split the view into server and client halves in the style of React Server Components. A Foldkit view remains one function of one Model. The server-client boundary stays at data and hydration, not inside the view tree. There are no `'use client'` or `'use server'` annotations and no second data-fetching model. Foldkit will not adopt JSX. [Why no JSX?](https://foldkit.dev/faq/why-no-jsx) explains the type-system constraint behind that decision. ## Following the Roadmap Releases land continuously, and every change is recorded in the [changelog](https://github.com/foldkit/foldkit/blob/main/packages/foldkit/CHANGELOG.md). Bugs and feature requests live in [GitHub issues](https://github.com/foldkit/foldkit/issues). For questions and discussion, join the [Discord](https://discord.gg/kav8VNxqGm). --- Source: https://foldkit.dev/react/coming-from-react Section: Docs # Coming from React If you know React, you already have the instincts for building declarative interfaces. Foldkit puts those instincts inside a different structure. React organizes behavior around components and Hooks. Foldkit organizes it around one Model, Messages, update, and a view. Foldkit does not compete with React on the brevity of a small component, and it is not trying to. Its first counter is longer because it names the state machine before the application needs much of one. That gap is deliberate. The examples below keep adding behavior to the same counter so you can see what the structure buys as effects and time enter the picture. ## A Simple Counter Here is a counter in React: ``` import { useState } from 'react' function Counter() { const [count, setCount] = useState(0) const handleClickIncrement = () => { setCount(count => count + 1) } return (

Count: {count}

) } ``` The Foldkit version separates state, events, transitions, and rendering: ``` import { Schema as S } from 'effect' import { type Update } from 'foldkit' import type { Document, HtmlBuilder } from 'foldkit/html' import { defineMessageUnion } from 'foldkit/message' import { evo } from 'foldkit/struct' // MODEL - Your entire application state const Model = S.Struct({ count: S.Number, }) type Model = typeof Model.Type // MESSAGE - Events that can happen in your app const Message = defineMessageUnion({ ClickedIncrement: {}, }) type Message = typeof Message.Type // UPDATE - How Messages change the Model const update = (model: Model, message: Message) => Message.match>(message, { ClickedIncrement: () => ({ model: evo(model, { count: count => count + 1 }), }), }) // VIEW - A pure function from Model to a Document const view = (model: Model, h: HtmlBuilder): Document => ({ title: `Count: ${model.count}`, body: h.div( [], [ h.p([], [`Count: ${model.count}`]), h.button([h.OnClick(Message.ClickedIncrement())], ['Increment']), ], ), }) ``` For one number and one button, React is more compact. Foldkit’s structure starts paying for itself when the same state participates in timers, network requests, keyboard input, or several views. The rest of this page adds one of those concerns at a time. ## Adding Auto-Count The next requirement is a play/pause button that increments the counter every second. React uses an Effect to synchronize an interval with `isAutoCounting`: ``` import { useEffect, useState } from 'react' const TICK_INTERVAL_MS = 1000 function Counter() { const [count, setCount] = useState(0) const [isAutoCounting, setIsPlaying] = useState(false) const handleClickIncrement = () => { setCount(count => count + 1) } const handleClickAutoCount = () => { setIsPlaying(isAutoCounting => !isAutoCounting) } useEffect(() => { if (!isAutoCounting) { return } const intervalId = setInterval(() => { setCount(count => count + 1) }, TICK_INTERVAL_MS) return () => clearInterval(intervalId) }, [isAutoCounting]) return (

Count: {count}

) } ``` The Effect starts the interval when auto-counting is active and returns the cleanup that stops it. React runs the cleanup before the Effect starts again and when the component unmounts. The functional state updater keeps the interval from depending on a captured `count`. Foldkit adds a Subscription and a `Ticked` Message: ``` import { Duration, Effect, Schema as S, Stream } from 'effect' import { Subscription, type Update } from 'foldkit' import type { Document, HtmlBuilder } from 'foldkit/html' import { defineMessageUnion } from 'foldkit/message' import { evo } from 'foldkit/struct' const TICK_INTERVAL_MS = 1000 // MODEL const Model = S.Struct({ count: S.Number, isAutoCounting: S.Boolean, }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ ClickedIncrement: {}, ClickedToggleAutoCount: {}, Ticked: {}, }) type Message = typeof Message.Type // SUBSCRIPTION const subscriptions = Subscription.make()(entry => ({ tick: entry( { isAutoCounting: S.Boolean }, { modelToDependencies: model => ({ isAutoCounting: model.isAutoCounting, }), dependenciesToStream: ({ isAutoCounting }) => Stream.when( Stream.tick(Duration.millis(TICK_INTERVAL_MS)).pipe( Stream.map(Message.Ticked), ), Effect.sync(() => isAutoCounting), ), }, ), })) // UPDATE const update = (model: Model, message: Message) => Message.match>(message, { ClickedIncrement: () => ({ model: evo(model, { count: count => count + 1 }), }), ClickedToggleAutoCount: () => ({ model: evo(model, { isAutoCounting: isAutoCounting => !isAutoCounting, }), }), Ticked: () => ({ model: evo(model, { count: count => count + 1 }) }), }) // VIEW const view = (model: Model, h: HtmlBuilder): Document => ({ title: `Count: ${model.count}`, body: h.div( [], [ h.p([], [`Count: ${model.count}`]), h.button([h.OnClick(Message.ClickedIncrement())], ['Increment']), h.button( [h.OnClick(Message.ClickedToggleAutoCount())], [model.isAutoCounting ? 'Stop' : 'Auto-Count'], ), ], ), }) ``` The Subscription emits `Ticked` while `isAutoCounting` is true. Foldkit scopes the Stream to that Model condition, so the runtime starts and stops it as the condition changes. The interval does not live in the view, and its ticks enter the application through the same update function as button clicks. ## Adding a Step Size Now the user can choose how much each manual click and timer tick adds. A naive React interval that reads `step` from its original closure keeps using that old value. Adding `step` to the Effect dependencies gives the interval the latest value, but also restarts the interval whenever the input changes. If the interval should keep its rhythm, React 19.2’s `useEffectEvent` lets the tick read the latest committed `step` without making `step` a synchronization dependency: ``` import { useEffect, useEffectEvent, useState } from 'react' const TICK_INTERVAL_MS = 1000 function Counter() { const [count, setCount] = useState(0) const [isAutoCounting, setIsPlaying] = useState(false) const [step, setStep] = useState(1) const handleClickIncrement = () => { setCount(count => count + step) } const handleClickAutoCount = () => { setIsPlaying(isAutoCounting => !isAutoCounting) } const onTick = useEffectEvent(() => { setCount(count => count + step) }) useEffect(() => { if (!isAutoCounting) { return } const intervalId = setInterval(() => onTick(), TICK_INTERVAL_MS) return () => clearInterval(intervalId) }, [isAutoCounting]) return (

Count: {count}

) } ``` The distinction is meaningful in React. `isAutoCounting` controls whether the external interval exists, so it is an Effect dependency. `step` is data read when the interval fires, so the Effect Event reads its current value without restarting the interval. The Hooks linter enforces where an Effect Event may be called and keeps it out of the dependency array. The Foldkit version adds `step` to the Model and handles `ChangedStep`: ``` import { Duration, Effect, Schema as S, Stream } from 'effect' import { Subscription, type Update } from 'foldkit' import type { Document, HtmlBuilder } from 'foldkit/html' import { defineMessageUnion } from 'foldkit/message' import { evo } from 'foldkit/struct' const TICK_INTERVAL_MS = 1000 // MODEL const Model = S.Struct({ count: S.Number, step: S.Number, isAutoCounting: S.Boolean, }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ ClickedIncrement: {}, ClickedToggleAutoCount: {}, ChangedStep: { step: S.Number }, Ticked: {}, }) type Message = typeof Message.Type // SUBSCRIPTION const subscriptions = Subscription.make()(entry => ({ tick: entry( { isAutoCounting: S.Boolean }, { modelToDependencies: model => ({ isAutoCounting: model.isAutoCounting, }), dependenciesToStream: ({ isAutoCounting }) => Stream.when( Stream.tick(Duration.millis(TICK_INTERVAL_MS)).pipe( Stream.map(Message.Ticked), ), Effect.sync(() => isAutoCounting), ), }, ), })) // UPDATE const update = (model: Model, message: Message) => Message.match>(message, { ClickedIncrement: () => ({ model: evo(model, { count: count => count + model.step }), }), ClickedToggleAutoCount: () => ({ model: evo(model, { isAutoCounting: isAutoCounting => !isAutoCounting, }), }), ChangedStep: ({ step }) => ({ model: evo(model, { step: () => step }) }), Ticked: () => ({ model: evo(model, { count: count => count + model.step }), }), }) // VIEW const view = (model: Model, h: HtmlBuilder): Document => ({ title: `Count: ${model.count}`, body: h.div( [], [ h.p([], [`Count: ${model.count}`]), h.label( [], [ 'Step: ', h.input([ h.OnInput(value => Message.ChangedStep({ step: Number(value) })), ]), ], ), h.button([h.OnClick(Message.ClickedIncrement())], ['Increment']), h.button( [h.OnClick(Message.ClickedToggleAutoCount())], [model.isAutoCounting ? 'Stop' : 'Auto-Count'], ), ], ), }) ``` Each `Ticked` Message is handled with the current Model, so `model.step` is current when update calculates the next count. The Subscription still depends only on whether auto-counting is active. There is no closure decision to make and no second mechanism for reading the latest value. The architectural difference React synchronizes an external resource from component state, so the Effect must distinguish values that control the resource from values read when it emits. Foldkit’s Subscription controls the resource from a Model condition and emits Messages. Update reads the current Model when each Message arrives. `useEffectEvent` is a good answer to the React problem. Foldkit does not create that problem. The timer emits a fact, and update decides what that fact means using the current Model. The Foldkit example can be tested below the view by passing Models and Messages directly to update. A view-level Scene test can exercise the same flow through the buttons and input. Neither test needs to wait for a real interval because `Ticked` is already a value the test can dispatch. ## Translating React Concepts The mappings below are starting points, not one-to-one replacements: React ecosystem Foldkit `useState` / component state Fields in the Model `useReducer` The update function and Message union Event-driven side effect A Command returned from update External event source tied to state A Subscription gated by Model dependencies DOM work tied to an element `Mount.define` or `Mount.defineStream` Stateful resource shared with Commands ManagedResource Context used for application state The Model Context used for services Effect services and Layers `useMemo` / `useCallback` Often no equivalent; `createLazy` and `createKeyedLazy` skip expensive view work when needed Custom Hook A domain module, pure helper, lifecycle primitive, or combination of them JSX Typed HTML builder functions Component props Function parameters Event handler A Message value or a function that constructs one React Router / TanStack Router Built-in typed routing Next.js SSG / SSR [Server rendering](https://foldkit.dev/core/server-rendering) , at build time or per request React Hook Form / Formik Model, Messages, and [field validation](https://foldkit.dev/core/field-validation) Headless UI / Radix UI [Foldkit UI](https://foldkit.dev/ui/overview) Error Boundary for an unexpected rendering crash [Crash view](https://foldkit.dev/core/crash-view) ; expected Effect failures return as Messages and become explicit Model state If you know Redux The Model-View-Update pattern will feel familiar. The Model resembles the store, Messages resemble actions, and update resembles a reducer. Foldkit’s update also returns Commands, and its Message union is exhaustively matched. ## FAQ --- Source: https://foldkit.dev/react/coming-from-tanstack-query Section: Docs # Coming from TanStack Query TanStack Query is excellent at what it does. It combines remote data, a keyed cache, and fetching policy behind hooks and a `QueryClient`. Foldkit has no `useQuery`, and it does not need one. The value a request produces and the policy that obtains it are two different things. [AsyncData](https://foldkit.dev/core/async-data) models the value. The Model, `update`, Commands, and Subscriptions define when work starts and what happens when it finishes. TanStack Query supplies policies such as `staleTime`, retries, invalidation, and refetch-on-focus as configuration. Foldkit supplies the shared state machine and leaves application policy as visible state transitions. The trade is a query runtime configured from the outside versus ordinary application code you can read and test with everything else. ## Translating Concepts Here is how common TanStack Query concepts map onto Foldkit: TanStack Query Foldkit `useQuery` An [AsyncData](https://foldkit.dev/core/async-data) field in the Model plus a fetch Command `data` / `error` / `status` / `fetchStatus` The six `AsyncData` states, mapped below Query cache (keyed by query key) Model state: one `AsyncData` field, or an `S.HashMap` of them keyed by id `placeholderData: keepPreviousData` / stale data on screen `Refreshing` and `Stale` , which retain the previous data `staleTime` / background refetch A Subscription gated on a Model condition, applying `AsyncData.revalidate` `staleTime: Infinity` `AsyncData.loadIfMissing` , followed by explicit revalidation when the application requires it Request deduplication `AsyncData.revalidateOrLoad` yields `None` while that field has a request in flight Out-of-order response handling Request context in the result Message, checked against the current Model in `update` `invalidateQueries` `AsyncData.revalidateOrLoad` plus the fetch Command, returned from `update` `useMutation` A Message and a Command, like any other effect Retries Effect’s `retry` and `Schedule` TanStack Query Devtools [Foldkit DevTools](https://foldkit.dev/core/devtools) , which inspects the Model and Message timeline ## Async State Is Model State `AsyncData` is a union of six states: `Idle`, `Loading`, `Refreshing`, `Failure`, `Stale`, and `Success`. `Refreshing` holds the previous data while a refetch is in flight. `Stale` holds the previous data after that refetch fails. Those variants make stale-while-revalidate and keep-stale-on-failure part of the value instead of conditions derived from several flags. There is no separate query cache. The Model is the cache. A single resource lives in one `AsyncData` field. A collection of resources keyed by id lives in an `S.HashMap` of those fields. A cache hit is data the application already holds. Here is the complete shape of a simple query. It uses one field, one Command, and two `update` arms: ``` // MODEL const Post = S.Struct({ id: S.String, title: S.String }) const PostsData = AsyncData.Schema(S.Array(Post), S.String) const Model = S.Struct({ posts: PostsData.schema, }) // MESSAGE const Message = defineMessageUnion({ EnteredPostsRoute: {}, SettledFetchPosts: { result: S.Result(S.Array(Post), S.String) }, }) // COMMAND const FetchPosts = Command.define('FetchPosts', { messages: [Message.SettledFetchPosts], execute: pipe( fetchPosts, Effect.result, Effect.map(result => Message.SettledFetchPosts({ result })), ), }) // UPDATE M.tagsExhaustive({ EnteredPostsRoute: () => Option.match(AsyncData.revalidateOrLoad(model.posts), { onNone: () => ({ model }), onSome: nextPosts => ({ model: evo(model, { posts: () => nextPosts }), commands: [FetchPosts()], }), }), SettledFetchPosts: ({ result }) => ({ model: evo(model, { posts: AsyncData.settle(result) }), }), }) ``` Each behavior is visible in the transition that implements it. `revalidateOrLoad` returns `None` while the field is already `Loading` or `Refreshing`, so the same update path does not start another request. A successful value moves to `Refreshing` when revalidated, keeping the current list on screen. A cold field moves to `Loading`. When the Command finishes, `settle` folds its `Result` into the field and preserves previous data as `Stale` if a refresh fails. The [API Cache example](https://foldkit.dev/example-apps/api-cache) adds a keyed cache, instant cache hits, invalidation, and background polling using the same primitives. It is the complete answer to what replaces the machinery around `useQuery`. ## Mapping Query Status The table below maps the common online query states. TanStack Query exposes `status`, `fetchStatus`, data presence, and derived flags independently, so it can represent more combinations than these six rows. TanStack Query AsyncData Disabled or not yet started ( `status: 'pending'` , `fetchStatus: 'idle'` ) `Idle` `isLoading` (first fetch, no data yet) `Loading` `isRefetching && data !== undefined` `Refreshing({ data })` `isError && data === undefined` `Failure({ error })` `isError && data !== undefined` `Stale({ error, data })` `isSuccess && !isFetching` `Success({ data })` A paused fetch uses `fetchStatus: 'paused'`, and placeholder data can produce a successful query before the real result arrives. `AsyncData` does not assign those behaviors automatically. If offline pause or placeholder provenance affects the interface, represent it in the Model alongside the `AsyncData` field. The difference is who tracks the combinations. In TanStack Query, failed data that remains available is a condition such as `isError && data !== undefined`. In Foldkit it is the `Stale` variant, and `AsyncData.match` requires the view to handle it. When a view only cares whether it has data, `matchData` collapses the six states into data, failure, and empty channels. isPending means something different TanStack Query’s `isPending` means the query has no data and no error yet. Its `isLoading` flag narrows that to a pending query that is actively fetching. `AsyncData.isPending` means a request is in flight, so it is true for both `Loading` and `Refreshing`. That is closer to TanStack Query’s `isFetching`. ## Out-of-Order Responses `AsyncData` models the state of one request lifecycle. It does not decide which of two independent responses should win. Imagine a search starts a request for A, then starts a request for B before A returns. B finishes first. If the slower A response then overwrites it, the screen shows results for a query the user no longer wants. Foldkit does not automatically cancel or order independent Commands. Thread the query through the Command into its result Message, then compare it with the current Model before accepting the result: ``` import { Effect, Schema as S, pipe } from 'effect' import { HttpClient, HttpClientRequest } from 'effect/unstable/http' import { AsyncData, Command, Http, type Update } from 'foldkit' import { defineMessageUnion } from 'foldkit/message' import { evo } from 'foldkit/struct' const SearchResult = S.Struct({ id: S.String, title: S.String }) const SearchResultsData = AsyncData.Schema(S.Array(SearchResult), S.String) // MODEL const Model = S.Struct({ queryInput: S.String, searchResults: SearchResultsData.schema, }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ UpdatedQuery: { query: S.String }, SettledSearch: { query: S.String, result: S.Result(S.Array(SearchResult), S.String), }, }) type Message = typeof Message.Type // COMMAND const Search = Command.define('Search', { args: { query: S.String }, messages: [Message.SettledSearch], execute: ({ query }) => pipe( Effect.gen(function* () { const client = yield* HttpClient.HttpClient const request = HttpClientRequest.get('/api/search').pipe( HttpClientRequest.setUrlParams({ q: query }), ) const response = yield* client.execute(request) return yield* S.decodeUnknownEffect(S.Array(SearchResult))( yield* response.json, ) }), Effect.mapError(error => String(error)), Effect.result, Effect.map(result => Message.SettledSearch({ query, result })), Effect.provide(Http.layer), ), }) // UPDATE const update = (model: Model, message: Message) => Message.match>(message, { UpdatedQuery: ({ query }) => ({ model: evo(model, { queryInput: () => query, searchResults: () => SearchResultsData.Loading(), }), commands: [Search({ query })], }), SettledSearch: ({ query, result }) => { if (query !== model.queryInput) { return { model } } return { model: evo(model, { searchResults: AsyncData.settle(result) }) } }, }) ``` The late response for A sees that its `query` no longer matches `queryInput`, so `update` leaves the Model unchanged. The comparison uses the context the application already cares about. The same pattern works for a search request launched after every keystroke: accept the result only if it still belongs to the current query. Receipt-time checks The guard is application policy, not incidental boilerplate. “Newest request wins” has to live somewhere. In Foldkit it is an explicit comparison against the current Model, in the same update function that decides what the result does. This snippet also shows where `keepPreviousData` belongs. `UpdatedQuery` currently moves the field to `Loading`, which removes the previous results while the new query runs. To retain them, move a data-holding state to `Refreshing({ data })` instead. When a superseded request is expensive or the user can cancel it, define the Command with an `interrupt` field and dispatch its `Interrupt` Command. Keep the receipt-time check as well, because a result may already be queued when the interrupt arrives. See [Interrupting Commands](https://foldkit.dev/core/commands#interrupting-commands). ## FAQ ### Where is useQuery? There is no equivalent hook, and you do not assemble one. A query is an [AsyncData](https://foldkit.dev/core/async-data) field in the [Model](https://foldkit.dev/core/model) plus a [Command](https://foldkit.dev/core/commands) returned from `update`. The runtime executes the Command and dispatches its result Message. `update` then folds the result into the field. ### How do I cache responses? Keep them in the Model. Use one `AsyncData` field for one resource or an `S.HashMap` keyed by id for many resources. A cache hit is a field for which `AsyncData.hasData` is true. See the [API Cache example](https://foldkit.dev/example-apps/api-cache). ### How do I deduplicate identical requests? Use `AsyncData.revalidateOrLoad` in the update path that starts the request. It yields `None` while that field is `Loading` or `Refreshing`, so the handler returns no Command. This prevents duplicate work through that transition. It is separate from the receipt-time check above, which rejects obsolete work that did start. ### What about keepPreviousData? Choose the transition when the input changes. `Loading` removes the old data. `Refreshing({ data })` retains it while the new request runs. TanStack Query v5 exposes the same choice through `placeholderData`, commonly using its `keepPreviousData` helper. ### How do I poll or refetch in the background? Use a [Subscription](https://foldkit.dev/core/subscriptions) gated on a Model condition. On each tick, apply `AsyncData.revalidate` and return the fetch Command when it yields a transition. `Success` and `Stale` move to `Refreshing`, while a tick during an in-flight request starts nothing. The Subscription is torn down when its Model condition becomes false. ### How do I invalidate and refetch? Apply `AsyncData.revalidateOrLoad` to the field and return the fetch Command when it yields a transition. Data-holding states move to `Refreshing`; a cold or failed field moves to `Loading`. The narrower `revalidate` skips fields that hold no data, which is useful when a mutation affects caches that may never have loaded. ### What about mutations? A mutation begins with a Message. Its update handler returns a Command that performs the write, and the result returns as another Message. That result handler can revalidate affected fields or update cached data directly with `AsyncData.map`. ### How do I do optimistic updates? Apply the optimistic edit to the Model with `AsyncData.map` in the same update arm that returns the mutation Command. Retain the previous value in the Model if the failure path needs to restore it, or revalidate after failure. ### Why write this yourself instead of letting a library do it? Foldkit ships the reusable state machine. Your application defines when to fetch, what counts as stale, and which fields a mutation refreshes. That policy differs from one application to the next, so Foldkit keeps it in ordinary Model state and update logic where the same tools and tests cover it. A query runtime gives you more behavior out of the box. It also owns that behavior until its configuration changes it. Foldkit makes the opposite trade. Owning the policy is the reason to write it yourself, not an accidental cost. The [Async Data](https://foldkit.dev/core/async-data) page covers the Schema builder, matching helpers, transitions, and combining fields with `all`. If you are also coming from React, [Coming from React](https://foldkit.dev/react/coming-from-react) covers components, hooks, effects, and their Foldkit counterparts. --- Source: https://foldkit.dev/core/routing-and-navigation Section: Docs # Routing & Navigation Foldkit uses a bidirectional routing system where you define routes once and use them for both parsing URLs and building URLs. No more keeping route matchers and URL builders in sync. This page introduces the pieces in the order you reach for them; the [Route API reference](https://foldkit.dev/api-reference/route) has the exhaustive catalog of combinators, and the [Route Transition API reference](https://foldkit.dev/api-reference/route-transition) covers the transition helpers. ## The Biparser Approach Most routers make you define routes twice: once for matching URLs, and again for generating them. This leads to duplication and bugs when they get out of sync. Foldkit’s routing is based on biparsers: parsers that work in both directions. A single route definition handles: - `/people/42` → `PersonRoute { personId: 42 }` (parsing) - `PersonRoute { personId: 42 }` → `/people/42` (building) This symmetry means if you can parse a URL into data, you can always build that data back into the same URL. ## Defining Routes Routes are defined as tagged unions using [Effect Schema](https://effect.website/docs/schema/introduction/). Each route variant carries the data extracted from the URL. ``` import { Schema as S } from 'effect' import { r } from 'foldkit/route' const HomeRoute = r('Home') const PeopleRoute = r('People', { searchText: S.Option(S.String) }) const PersonRoute = r('Person', { personId: S.Number }) const NotFoundRoute = r('NotFound', { path: S.String }) const AppRoute = S.Union([HomeRoute, PeopleRoute, PersonRoute, NotFoundRoute]) type AppRoute = typeof AppRoute.Type ``` - `HomeRoute`: no parameters - `PersonRoute`: holds a `personId: number` - `PeopleRoute`: holds an optional `searchText: Option` - `NotFoundRoute`: holds the unmatched `path: string` ## Building Routers Routers are built by composing small primitives. Each primitive is a biparser that handles one part of the URL. ``` import { Schema as S, pipe } from 'effect' import { Route } from 'foldkit' import { int, literal, slash } from 'foldkit/route' // Matches: / const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute)) // Matches: /people or /people?searchText=alice const peopleRouter = pipe( literal('people'), Route.query( S.Struct({ searchText: S.OptionFromOptional(S.String), }), ), Route.mapTo(PeopleRoute), ) // Matches: /people/42 const personRouter = pipe( literal('people'), slash(int('personId')), Route.mapTo(PersonRoute), ) ``` The primitives: - `literal('people')`: matches the exact segment `people` - `int('personId')`: captures an integer parameter - `string('name')`: captures a string parameter - `schemaSegment('personId', PersonId)`: captures a segment decoded through a Schema - `rest('path')`: captures all remaining segments - `restString('path')`: captures all remaining segments as one path string - `slash(...)`: chains path segments together - `Route.query(Schema)`: adds query parameter parsing - `Route.mapTo(RouteType)`: converts parsed data into a typed route ## Parsing URLs Combine routers with `Route.oneOf` and create a parser with a fallback for unmatched URLs. ``` import { Route, Runtime } from 'foldkit' import { evo } from 'foldkit/struct' import { Url } from 'foldkit/url' // Combine routers. A route matches only when it consumes the whole URL. const routeParser = Route.oneOf( personRouter, // /people/:id peopleRouter, // /people?search=... homeRouter, // / ) // Create a parser with a fallback for unmatched URLs const urlToAppRoute = Route.parseUrlWithFallback(routeParser, NotFoundRoute) // In your init function, parse the initial URL: const init: Runtime.RoutingApplicationInit = (url: Url) => { return { model: { route: urlToAppRoute(url) } } } // In your update function, handle URL changes: ChangedUrl: ({ url }) => ({ model: evo(model, { route: () => urlToAppRoute(url), }), }) ``` A router only matches when it consumes the entire URL, so routes that share a prefix do not conflict. `/people` and `/people/:id` can appear in any order. When several routes fully match the same URL, the first one wins. That only happens when route shapes overlap, like a `literal('new')` page next to a `string('username')` profile: `/users/new` satisfies both, so list the literal route first. ## Building URLs Here’s where the biparser pays off. The same router that parses URLs can build them: ``` // Building URLs from route data - same router, opposite direction! const homeUrl = homeRouter() console.log(homeUrl) // '/' const peopleUrl = peopleRouter({ searchText: Option.none() }) console.log(peopleUrl) // '/people' const searchUrl = peopleRouter({ searchText: Option.some('alice'), }) console.log(searchUrl) // '/people?searchText=alice' const personUrl = personRouter({ personId: 42 }) console.log(personUrl) // '/people/42' // Use in your view to create type-safe links: a([Href(personRouter({ personId: person.id }))], [person.name]) ``` TypeScript ensures you provide the correct data. If `personRouter` expects `{ personId: number }`, you can’t accidentally pass a string or forget the parameter. ## Query Parameters Query parameters use [Effect Schema](https://effect.website/docs/schema/introduction/) for validation. This gives you type-safe parsing, optional parameters, and automatic encoding/decoding. ``` import { Schema as S, pipe } from 'effect' import { Route } from 'foldkit' import { literal } from 'foldkit/route' // Query parameters use Effect Schema for validation const searchRouter = pipe( literal('search'), Route.query( S.Struct({ q: S.OptionFromOptional(S.String), page: S.OptionFromOptional(S.FiniteFromString), sort: S.OptionFromOptional(S.Literals(['Asc', 'Desc'])), }), ), Route.mapTo(SearchRoute), ) // Parsing /search?q=hello&page=2&sort=asc gives you: // → SearchRoute { q: Some('hello'), page: Some(2), sort: Some('Asc') } // Building const searchUrl = searchRouter({ q: Option.some('hello'), page: Option.some(2), sort: Option.none(), }) console.log(searchUrl) // '/search?q=hello&page=2' ``` `S.OptionFromOptional` makes parameters optional. Missing params become `Option.none()`. `S.FiniteFromString` automatically parses string query values into numbers. For a complete routing example, see the [Routing example](https://foldkit.dev/example-apps/routing). For a deeper look at query parameters (custom schema transforms, lenient parsing, and bidirectional URL sync), see the [Query Sync example](https://foldkit.dev/example-apps/query-sync). ## Schema Segments `int` and `string` capture a segment as a bare `number` or `string`. When a segment is really a domain id, `schemaSegment` decodes it through an [Effect Schema](https://effect.website/docs/schema/introduction/) instead, so the route carries the schema’s type. A branded `PersonId` flows straight into the Model, where it can’t be passed anywhere a different id or a bare `number` is expected. ``` import { Schema as S, pipe } from 'effect' import { Route } from 'foldkit' import { literal, r, schemaSegment, slash } from 'foldkit/route' // A branded id: structurally a number, but its own type. The brand stops it // from being mixed up with another number, like an OrderId or a count. const PersonId = S.FiniteFromString.pipe(S.brand('PersonId')) type PersonId = typeof PersonId.Type const PersonRoute = r('Person', { personId: PersonId }) // int('personId') captures a bare number. schemaSegment decodes the segment // through the schema, so the route carries a PersonId instead. // // Parses: /people/42 → PersonRoute { personId: PersonId(42) } const personRouter = pipe( literal('people'), slash(schemaSegment('personId', PersonId)), Route.mapTo(PersonRoute), ) // Builds: /people/42. The brand is required, so a bare number or a different // id type is a compile error. const personUrl = personRouter({ personId: PersonId.make(42) }) ``` Whether a segment decodes is the route’s match test, and the decoded value is what the route carries when it passes. `int` already works this way: it claims `/users/42` but not `/users/banana`. `schemaSegment` generalizes that to any rule a schema can express, from a UUID pattern to a fixed set of string literals. Refine a `ProductId` to a UUID and the route matches a real one but declines `/products/banana`, so a malformed id falls through to the next route in `oneOf` (or to not-found) rather than reaching a component that has to handle it. Refinement and a brand compose, so one segment is both validated and carried as a distinct type. ``` import { Schema as S, pipe } from 'effect' import { Route } from 'foldkit' import { literal, r, schemaSegment, slash } from 'foldkit/route' // A refinement, not a transform: the value stays a string, but the route only // matches when the segment is actually a UUID. The brand rides along, so the // model carries a ProductId distinct from any other string. const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const ProductId = S.String.check(S.isPattern(UUID_PATTERN)).pipe( S.brand('ProductId'), ) type ProductId = typeof ProductId.Type const ProductRoute = r('Product', { productId: ProductId }) // Matches /products/. /products/banana does not match, so in oneOf it // falls through to the next route, or to not-found. const productRouter = pipe( literal('products'), slash(schemaSegment('productId', ProductId)), Route.mapTo(ProductRoute), ) // Building still round-trips: a ProductId prints straight back into the path. const productUrl = productRouter({ productId: ProductId.make('3f2504e0-4f89-41d3-9a0c-0305e82c3301'), }) ``` The schema’s encoded form must be a single segment string, and `schemaSegment` runs it both ways: it decodes when parsing and encodes when building, so the route still round-trips. For values that span several segments use `rest`, and for values in the query string use `Route.query`. ## Rest Segments Some routes carry a whole path as data: a file tree, a documentation page, a breadcrumb trail. `rest` captures every remaining segment as a named field, the feature other routers call catch-all or splat routes. The parsed value is a non-empty array of strings, so the route schema declares the field with `S.NonEmptyArray(S.String)`. ``` import { Schema as S, pipe } from 'effect' import { Route } from 'foldkit' import { literal, r, rest, slash } from 'foldkit/route' const FilesIndexRoute = r('FilesIndex') const FilesRoute = r('Files', { path: S.NonEmptyArray(S.String) }) // Matches: /files const filesIndexRouter = pipe(literal('files'), Route.mapTo(FilesIndexRoute)) // Matches: /files/documents/taxes/2024.pdf // path: ['documents', 'taxes', '2024.pdf'] const filesRouter = pipe( literal('files'), slash(rest('path')), Route.mapTo(FilesRoute), ) // Builds: /files/documents/taxes const taxesUrl = filesRouter({ path: ['documents', 'taxes'] }) ``` `rest` requires at least one segment, so the bare prefix `/files` does not match the rest route. Give the prefix its own route, like `FilesIndexRoute` above. The two never overlap: one matches exactly `/files`, the other matches anything beneath it. A specific route under the same prefix is different. The rest route also matches every URL that `literal('files'), slash(literal('shared'))` accepts, so in `oneOf` the specific route must come first. Nothing can follow `rest` in the path, so `slash` cannot extend it. TypeScript rejects the composition. `query` can still follow, since query parameters live after the path. When the path itself is the value, `restString` captures the same tail as a single string, slashes included, so the route schema declares the field with `S.String`. A repository-relative file path like `20-upgrade/teach/the-elm-architecture.md` round-trips as one value instead of an array of segments. ``` import { Schema as S, pipe } from 'effect' import { Route } from 'foldkit' import { literal, r, restString, slash } from 'foldkit/route' const VaultIndexRoute = r('VaultIndex') const VaultNoteRoute = r('VaultNote', { path: S.String }) // Matches: /vault const vaultIndexRouter = pipe(literal('vault'), Route.mapTo(VaultIndexRoute)) // Matches: /vault/20-upgrade/teach/the-elm-architecture.md // path: '20-upgrade/teach/the-elm-architecture.md' const vaultNoteRouter = pipe( literal('vault'), slash(restString('path')), Route.mapTo(VaultNoteRoute), ) // Builds: /vault/20-upgrade/teach/the-elm-architecture.md const noteUrl = vaultNoteRouter({ path: '20-upgrade/teach/the-elm-architecture.md', }) ``` Everything above about `rest` applies to `restString` as well: it requires at least one segment, a more specific route under the same prefix must come first in `oneOf`, and nothing can follow it in the path. Building requires a normalized path, non-empty with no leading, trailing, or repeated slashes. Any other value would build a URL that parses back differently, so the build fails instead. The [Routing example](https://foldkit.dev/example-apps/routing) uses a rest route to drive a small file browser, building breadcrumb and directory links from the captured segments. ## Route View Identity Each route arm delegates to its own view function, and view functions are identity boundaries: the build brands the VNodes a function returns with that function’s identity, and the differ replaces a position whose identity changed instead of patching it. Navigating from one route to another therefore tears down the old page and builds the new one fresh, with no keys and no wrapper elements. The identity is stamped by `@foldkit/vite-plugin`, which `create-foldkit-app` includes by default. Do not build a Foldkit app without it: ``` import { Match as M } from 'effect' import type { Document, HtmlBuilder } from 'foldkit/html' const view = (model: Model, h: HtmlBuilder): Document => { const routeContent = M.value(model.route).pipe( M.tagsExhaustive({ Products: () => productsView(model, h), Cart: () => cartView(model, h), Checkout: () => checkoutView(model, h), NotFound: ({ path }) => notFoundView(path, h), }), ) return { title: `${model.route._tag} | Shop`, body: h.div( [], [ h.header([], [navigationView(model.route, h)]), h.main([], [routeContent]), ], ), } } ``` Route views are the most common branch, but the same protection applies to any control flow that selects between view functions. See [Keying](https://foldkit.dev/best-practices/keying) in Best Practices for the full identity model, list keys, and the edges that remain manual. ## Navigation Foldkit provides navigation Commands for programmatically changing the URL. These are returned from your update function like any other Command. ``` import { Effect, Schema as S } from 'effect' import { Command, Navigation } from 'foldkit' import { defineMessageUnion } from 'foldkit/message' const Message = defineMessageUnion({ CompletedNavigateInternal: {}, CompletedReplaceUrl: {}, CompletedGoBack: {}, CompletedGoForward: {}, CompletedLoadExternal: {}, CompletedOpenUrl: {}, }) type Message = typeof Message.Type const NavigateInternal = Command.define('NavigateInternal', { args: { url: S.String }, messages: [Message.CompletedNavigateInternal], execute: ({ url }) => Navigation.pushUrl(url).pipe( Effect.as(Message.CompletedNavigateInternal()), ), }) const ReplaceUrl = Command.define('ReplaceUrl', { args: { url: S.String }, messages: [Message.CompletedReplaceUrl], execute: ({ url }) => Navigation.replaceUrl(url).pipe(Effect.as(Message.CompletedReplaceUrl())), }) const GoBack = Command.define('GoBack', { messages: [Message.CompletedGoBack], execute: Navigation.back().pipe(Effect.as(Message.CompletedGoBack())), }) const GoForward = Command.define('GoForward', { messages: [Message.CompletedGoForward], execute: Navigation.forward().pipe(Effect.as(Message.CompletedGoForward())), }) const LoadExternal = Command.define('LoadExternal', { args: { href: S.String }, messages: [Message.CompletedLoadExternal], execute: ({ href }) => Navigation.load(href).pipe(Effect.as(Message.CompletedLoadExternal())), }) const OpenUrl = Command.define('OpenUrl', { args: { url: S.String }, messages: [Message.CompletedOpenUrl], execute: ({ url }) => Navigation.openUrl(url).pipe(Effect.as(Message.CompletedOpenUrl())), }) ``` - `Navigation.pushUrl`: adds a new entry to browser history - `Navigation.replaceUrl`: replaces the current history entry (no back button) - `Navigation.back` / `Navigation.forward`: navigate through browser history - `Navigation.load`: full page load (for external URLs) - `Navigation.openUrl`: opens an external URL in a new browsing context (tab or window), leaving the current page untouched When a link is clicked in your application, the `routing.onUrlRequest` handler receives either an Internal or External request. Handle Internal links with `pushUrl` and External links with `load`: ``` import { Effect, Match as M, Schema as S, pipe } from 'effect' import { Command, Navigation, Route, type Update, Url } from 'foldkit' import { defineMessageUnion } from 'foldkit/message' import { int, literal, r, slash } from 'foldkit/route' import { evo } from 'foldkit/struct' // ROUTE const HomeRoute = r('Home') const PersonRoute = r('Person', { personId: S.Number }) const NotFoundRoute = r('NotFound', { path: S.String }) const AppRoute = S.Union([HomeRoute, PersonRoute, NotFoundRoute]) type AppRoute = typeof AppRoute.Type const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute)) const personRouter = pipe( literal('people'), slash(int('personId')), Route.mapTo(PersonRoute), ) const routeParser = Route.oneOf(personRouter, homeRouter) const urlToAppRoute = Route.parseUrlWithFallback(routeParser, NotFoundRoute) // MODEL const Model = S.Struct({ route: AppRoute }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ CompletedNavigateInternal: {}, CompletedLoadExternal: {}, ClickedLink: { request: Navigation.UrlRequest }, ChangedUrl: { url: Url.Url }, }) type Message = typeof Message.Type // COMMAND const NavigateInternal = Command.define('NavigateInternal', { args: { url: S.String }, messages: [Message.CompletedNavigateInternal], execute: ({ url }) => Navigation.pushUrl(url).pipe( Effect.as(Message.CompletedNavigateInternal()), ), }) const LoadExternal = Command.define('LoadExternal', { args: { href: S.String }, messages: [Message.CompletedLoadExternal], execute: ({ href }) => Navigation.load(href).pipe(Effect.as(Message.CompletedLoadExternal())), }) // UPDATE type UpdateReturn = Update.Return const update = (model: Model, message: Message) => Message.match(message, { CompletedNavigateInternal: () => ({ model }), CompletedLoadExternal: () => ({ model }), ClickedLink: ({ request }) => M.value(request).pipe( M.withReturnType(), M.tagsExhaustive({ Internal: ({ url }) => ({ model, commands: [NavigateInternal({ url: Url.toString(url) })], }), External: ({ href }) => ({ model, commands: [LoadExternal({ href })], }), }), ), ChangedUrl: ({ url }) => ({ model: evo(model, { route: () => urlToAppRoute(url), }), }), }) ``` After `pushUrl` or `replaceUrl` changes the URL, Foldkit automatically calls your `routing.onUrlChange` handler with the new URL. This is where you parse the URL into a route and update your model. ## Cold Loads and the Initial Route `onUrlChange` fires when the URL changes after boot. On a cold load (a direct visit, a bookmark, a reload) there is no change to report: `init` receives the initial URL, parses it, and seeds the Model with the starting route. Foldkit does not synthesize a `ChangedUrl` for it, because the initial route is starting state, not a transition. Don’t wire route fetches into navigation alone. A fetch Command returned only from the `ChangedUrl` handler fires on every in-app navigation and never on a cold load. During development you reach every route by clicking from the home page, so everything works. Then a user reloads on a sub-route or follows a bookmark and lands on a Model stuck in its initial state, with no fetch in flight. Both code paths resolve a URL into a route, and both should produce the same route-driven Commands. Factor those Commands into one helper and call it from both places: ``` import { Match as M, Option } from 'effect' import { Command, Runtime } from 'foldkit' import { evo } from 'foldkit/struct' import { Url } from 'foldkit/url' // Route-driven Commands live in one helper... const commandsForRoute = ( route: AppRoute, ): ReadonlyArray> => M.value(route).pipe( M.withReturnType>>(), M.tag('People', ({ searchText }) => [ FetchPeople({ searchText: Option.getOrElse(searchText, () => '') }), ]), M.orElse(() => []), ) // ...which init calls for the cold load... const init: Runtime.RoutingApplicationInit = (url: Url) => { const route = urlToAppRoute(url) return { model: { route }, commands: commandsForRoute(route) } } // ...and the ChangedUrl handler calls for in-app navigation: ChangedUrl: ({ url }) => { const route = urlToAppRoute(url) return { model: evo(model, { route: () => route }), commands: commandsForRoute(route), } } ``` When the route-driven state lives in a Submodel, the same factoring follows the Submodel boundary instead of a shared helper: the Submodel’s `init(route)` seeds its state and returns the boot Commands for the cold load, and its `informRouteChanged` helper covers later transitions. [Informing Submodels](https://foldkit.dev/patterns/informing-submodels) shows that shape, and the [Routing example](https://foldkit.dev/example-apps/routing) runs on it. ## Route Transitions The shared helper above answers what a route needs, so its Commands fire on every navigation that lands on the route. For `FetchPeople` that is the point: every search text is a new query. Other Commands should run once when the user arrives, loading a filter catalog, starting a poll, recording a page view. For those the route alone cannot answer the real question: did this navigation enter the route, or was the application already there? The `Transition` namespace in `foldkit/route` answers it. A `Transition.Transition` carries both halves of the question: the route the application was on and the route it is on now. `Transition.make(previousRoute, nextRoute)` builds the navigation case, `Transition.coldLoad(nextRoute)` builds the cold load, where there is no previous route, and `Transition.isEntering` asks the question: a transition enters a route when the next route carries the tag and the previous route did not, and a cold load counts as an entry. Navigating within a route, between two ids of one detail route or two search texts of one list route, is not an entry. The route union is inferred from the transition argument and the tag is checked against it, so a misspelled route name fails to compile. Build the transition in the same two places that resolve a URL into a route: `init` holds no route yet, so it builds the cold load, and the `ChangedUrl` handler transitions from the route the Model still holds: ``` import { Command, Runtime } from 'foldkit' import { Transition } from 'foldkit/route' import { evo } from 'foldkit/struct' import { Url } from 'foldkit/url' // Entry-only Commands ask about the transition, not the route alone... const commandsForTransition = ( transition: Transition.Transition, ): ReadonlyArray> => Transition.isEntering(transition, 'People') ? [FetchPeopleFilters()] : [] // ...init builds the cold load transition, which counts as an entry... const init: Runtime.RoutingApplicationInit = (url: Url) => { const route = urlToAppRoute(url) return { model: { route }, commands: commandsForTransition(Transition.coldLoad(route)), } } // ...and the ChangedUrl handler transitions from the route the Model holds: ChangedUrl: ({ url }) => { const nextRoute = urlToAppRoute(url) return { model: evo(model, { route: () => nextRoute }), commands: commandsForTransition(Transition.make(model.route, nextRoute)), } } ``` A predicate answers whether, not which. When the entry Command needs the route’s payload, `Transition.entered(transition, tag)` returns the entered route narrowed to the tag, so a detail id arrives typed: ``` import { Option } from 'effect' import { Command } from 'foldkit' import { Transition } from 'foldkit/route' type Commands = ReadonlyArray> const commandsForTransition = ( transition: Transition.Transition, ): Commands => Option.match(Transition.entered(transition, 'Person'), { onNone: () => [], onSome: ({ personId }) => [FetchPerson({ personId })], }) ``` Every helper that takes a tag answers for one named route. When several routes have entry Commands, ask the transition which route it entered instead: `Transition.enteredAny` returns the entered route in a `Some`, whichever route that was, and `Option.none()` when the transition stayed within one route. Match on the result to dispatch every entry policy in one place: ``` import { Match as M, Option } from 'effect' import { Command } from 'foldkit' import { Transition } from 'foldkit/route' type Commands = ReadonlyArray> const commandsForTransition = ( transition: Transition.Transition, ): Commands => Option.match(Transition.enteredAny(transition), { onNone: () => [], onSome: M.type().pipe( M.withReturnType(), M.tag('People', () => [FetchPeopleFilters()]), M.tag('Person', ({ personId }) => [FetchPerson({ personId })]), M.orElse(() => []), ), }) ``` Entering has a mirror. `Transition.exited(transition, tag)` returns the route the transition left, narrowed to the tag, and `Transition.exitedAny` is its whichever-route form. Exits are for one-shot Commands on the way out, saving a draft, recording that a visit ended. They are not for tearing down things that live while a route is active: listeners, timers, and handles belong to a [Subscription](https://foldkit.dev/core/subscriptions) or [ManagedResource](https://foldkit.dev/core/managed-resources) condition on the Model, which also ends them when the route state disappears for reasons other than navigation. The last case is staying. `Transition.stayed(transition, tag)` returns both sides of a within-route navigation, narrowed to the tag: `Some({ previousRoute, nextRoute })` when the transition stayed on that route, `Option.none()` when it entered it, left it, or never touched it. A cold load stays nowhere. Reach for it when the previous payload matters, comparing a detail id or diffing query parameters; when only the next value matters, the `ChangedUrl` handler already has the next route. Staying has no whichever-route form: without a tag the two sides could not narrow to the same route variant together, so matching on one would leave the other typed as the whole union. ``` import { Array, Option } from 'effect' import { Command } from 'foldkit' import { Transition } from 'foldkit/route' type Commands = ReadonlyArray> // Leaving a route is a fact too: one-shot Commands on the way out... const commandsOnExit = ( transition: Transition.Transition, ): Commands => Option.match(Transition.exited(transition, 'Person'), { onNone: () => [], onSome: ({ personId }) => [RecordVisitEnded({ personId })], }) // ...and stayed hands you both sides of a within-route change: const commandsOnPersonChange = ( transition: Transition.Transition, ): Commands => Option.match(Transition.stayed(transition, 'Person'), { onNone: () => [], onSome: ({ previousRoute, nextRoute }) => previousRoute.personId === nextRoute.personId ? [] : [FetchPerson({ personId: nextRoute.personId })], }) // Transition helpers compose by concatenation const commandsForTransition = ( transition: Transition.Transition, ): Commands => Array.flatten([ commandsOnExit(transition), commandsOnPersonChange(transition), ]) ``` Because a cold load counts as an entry, `init` and the `ChangedUrl` handler share one load-on-entry policy: reloading on `/people` runs the same entry Commands as clicking there from the home page. Transition helpers compose by concatenation, as above; a handler that mixes entry, exit, and per-navigation Commands flattens their results into one batch. The [Route Transition API reference](https://foldkit.dev/api-reference/route-transition) lists every helper with its full signature. --- Source: https://foldkit.dev/core/field-validation Section: Docs # Field Validation Foldkit models field validation as data in your Model, not scattered logic across event handlers. Each field is a four-state discriminated union: `NotValidated`, `Validating`, `Valid`, and `Invalid`. This makes it impossible to render a success indicator while an error exists, or show a spinner when validation is already complete. ## Defining a Field `makeRules` takes an options object and returns a `Rules` bundle. `Field(valueSchema)` builds the four-state Schema you put in your Model. ``` import { Schema as S } from 'effect' import { Field, Rule, makeRules } from 'foldkit/fieldValidation' // Optional: no `required` option. The rule applies when the user fills it in. const usernameRules = makeRules({ rules: [Rule.minLength(3, 'Must be at least 3 characters')], }) // Required: empty values become `Invalid` with the given message. const emailRules = makeRules({ required: 'Email is required', rules: [Rule.email('Please enter a valid email address')], }) // Non-string fields work too. The value Schema is what the control holds, // so a multi-select holds an array. Annotate the value type on `makeRules`. const interestsRules = makeRules>({ required: 'Pick at least one interest', rules: [Rule.maxItems(5, 'Choose up to five')], }) const Model = S.Struct({ username: Field(S.String), email: Field(S.String), interests: Field(S.Array(S.String)), }) type Model = typeof Model.Type ``` Every state carries the current `value`. `Invalid` also carries a non-empty `errors` array. Variant Meaning `NotValidated` Validation has not run. `Validating` An async check is in flight. `Valid` Every applicable rule passed. `Invalid` One or more rules failed, with the errors. The Schema you pass `Field` should match what the control actually holds as the user edits, not the type you parse it into: `Field(S.String)` for text inputs, `Field(S.Array(S.String))` for a multi-select. A scalar like a checkbox’s boolean usually stays plain `S.Boolean` in the Model; wrap it in `Field` only when it needs the validation lifecycle. Values you reach by parsing text, like numbers and dates, stay `Field(S.String)`: a half-typed entry is still a string, so parse it into its domain type on submit. Validation rules stay separate, in the `Rules` bundle. Each entry in the `rules` array is a `Rule`: a `[predicate, errorMessage]` tuple. Error messages can be static strings or functions that receive the invalid value. Foldkit ships built-in rules for common cases; see [Custom Rules](#custom-rules) to write your own. Operations are free module functions that take a `Rules` bundle as their first argument. `Rules` itself has no methods; the sections below introduce each operation. To construct a state directly (e.g. initial Model values, async Command results), use the module-level constructors: `NotValidated`, `Validating`, `Valid`, `Invalid`. ### Conditional Rules A `Rules` bundle is just data, so build it from model state via a plain function. ``` import { Rule, makeRules, validate } from 'foldkit/fieldValidation' // A function that builds the bundle from whatever state it depends on. const companyNameRules = (accountType: 'Personal' | 'Business') => makeRules({ ...(accountType === 'Business' && { required: 'Required for business accounts', }), rules: [Rule.maxLength(100)], }) const validateCompanyName = ( accountType: 'Personal' | 'Business', value: string, ) => validate(companyNameRules(accountType))(value) ``` ## Applying Validation Call `validate(rules)(value)` to validate a value against a bundle of rules. It returns one of the four `Field` variants, failing fast at the first rule that fails. Use it in your update function with `evo` to set the field state. ``` import { Update } from 'foldkit' import { validate } from 'foldkit/fieldValidation' import { evo } from 'foldkit/struct' const validateUsername = validate(usernameRules) const update = (model: Model, message: Message) => Message.match>(message, { ChangedUsername: ({ value }) => ({ model: evo(model, { username: () => validateUsername(value), }), }), }) ``` Empty values follow the bundle’s requiredness before any rules run. An empty required value becomes `Invalid` with the required message; an empty optional value becomes `NotValidated`. A non-empty value becomes `Valid` when every rule passes or `Invalid` when one fails. Use `validateAll(rules)` when you want to collect every failing rule into the `errors` array rather than stopping at the first failure. Requiredness behaves the same way in both functions. ## Displaying Validation State Match exhaustively on the four tags to derive border colors, status indicators, and error messages. For a single-field submit gate, use `isValid(rules)(state)`. If the rules are required, only `Valid` passes; if they are optional, `NotValidated` also passes. `Validating` and `Invalid` never pass. For a form-level gate, pass `[state, rules]` pairs to `allValid`. A single call gates fields of one value type, so a form that mixes types calls `allValid` per type and combines the results with `&&`. ``` import { Array, Match as M } from 'effect' import { type Field, allValid } from 'foldkit/fieldValidation' import type { HtmlBuilder } from 'foldkit/html' const borderClass = (field: Field) => M.value(field).pipe( M.tagsExhaustive({ NotValidated: () => 'border-gray-300', Validating: () => 'border-accent-300', Valid: () => 'border-accent-500', Invalid: () => 'border-red-500', }), ) const statusIndicator = (field: Field, h: HtmlBuilder) => M.value(field).pipe( M.tagsExhaustive({ NotValidated: () => h.empty, Validating: () => h.span([], ['Checking...']), Valid: () => h.span([], ['✓']), Invalid: ({ errors }) => h.div([], [Array.headNonEmpty(errors)]), }), ) // `allValid` gates fields of one value type per call; required rules demand // `Valid`, optional rules also accept `NotValidated`. For a form that mixes // value types, call `allValid` per type and combine with `&&`. const isFormValid = (model: Model): boolean => allValid([ [model.username, usernameRules], [model.email, emailRules], ]) ``` Because `Field` is a discriminated union, the exhaustive match ensures you handle every state. Use `isInvalid(state)` or `anyInvalid(states)` when you specifically need to know whether validation has produced errors. They check for the `Invalid` tag. A required `NotValidated` field and a `Validating` field are not invalid, but they still fail an `isValid` submit gate. ## Async Validation For server-side checks like “Is this email taken?”, use the `Validating` state as a bridge: run sync `validate` first, then transition to `Validating`, fire a Command, and handle the result message. ``` import { Effect, Match as M, Number, Schema as S } from 'effect' import { Command, Update } from 'foldkit' import { Invalid, Valid, Validating, validate } from 'foldkit/fieldValidation' import { evo } from 'foldkit/struct' const validateEmail = validate(emailRules) const CheckEmailAvailable = Command.define('CheckEmailAvailable', { args: { email: S.String, validationId: S.Number }, messages: [CompletedCheckEmailAvailable], execute: ({ email, validationId }) => Effect.gen(function* () { const isAvailable = yield* apiCheckEmail(email) return CompletedCheckEmailAvailable({ validationId, field: isAvailable ? Valid({ value: email }) : Invalid({ value: email, errors: ['This email is already taken'], }), }) }).pipe( Effect.catch(() => Effect.succeed( CompletedCheckEmailAvailable({ validationId, field: Invalid({ value: email, errors: ['Could not check this email. Try again.'], }), }), ), ), ), }) const update = (model: Model, message: Message) => Message.match>(message, { ChangedEmail: ({ value }) => { const syncResult = validateEmail(value) const validationId = Number.increment(model.emailValidationId) return M.value(syncResult).pipe( M.tag('Valid', () => ({ model: evo(model, { email: () => Validating({ value }), emailValidationId: () => validationId, }), commands: [CheckEmailAvailable({ email: value, validationId })], })), M.orElse(() => ({ model: evo(model, { email: () => syncResult, emailValidationId: () => validationId, }), })), ) }, CompletedCheckEmailAvailable: ({ validationId, field }) => { if (validationId === model.emailValidationId) { return { model: evo(model, { email: () => field }) } } else { return { model } } }, }) ``` The `validationId` pattern prevents race conditions. Each keystroke increments the ID, and the result handler only applies if the ID still matches. Responses from superseded requests are silently discarded. ## Custom Rules A `Rule` is a `[predicate, errorMessage]` tuple. Write your own by pairing any predicate with an error message (a static string, or a function that receives the value). ``` import { Rule } from 'foldkit/fieldValidation' const noConsecutiveSpaces: Rule.Rule = [ value => !/ /.test(value), 'Cannot contain consecutive spaces', ] const hasUppercase: Rule.Rule = [ value => /[A-Z]/.test(value), 'Must contain at least one uppercase letter', ] // Messages can be functions that receive the failing value: const noTrailingWhitespace: Rule.Rule = [ value => value === value.trimEnd(), value => `Remove the trailing whitespace from "${value}"`, ] ``` Custom rules compose with built-in ones in the same `rules` array. ## Cross-Field Validation A `Rule` only sees a single value. For checks that compare fields against each other (like “confirm password must match password”), handle the logic directly in your update function where you have access to the full model. ``` import { Update } from 'foldkit' import { type Field, Invalid, Rule, makeRules, validate, } from 'foldkit/fieldValidation' import { evo } from 'foldkit/struct' const passwordRules = makeRules({ required: 'Password is required', rules: [Rule.minLength(8, 'Must be at least 8 characters')], }) const validatePassword = validate(passwordRules) const validateConfirmPassword = ( password: string, confirmPassword: string, ): Field => { const result = validatePassword(confirmPassword) if (result._tag === 'Valid' && result.value !== password) { return Invalid({ value: confirmPassword, errors: ['Passwords must match'], }) } return result } const update = (model: Model, message: Message) => Message.match>(message, { ChangedPassword: ({ value }) => ({ model: evo(model, { password: () => validatePassword(value), confirmPassword: confirmPassword => confirmPassword._tag === 'NotValidated' ? confirmPassword : validateConfirmPassword(value, confirmPassword.value), }), }), ChangedConfirmPassword: ({ value }) => ({ model: evo(model, { confirmPassword: () => validateConfirmPassword(model.password.value, value), }), }), }) ``` Keep cross-field logic in update only when the check genuinely needs more than one value. Anything expressible as `[predicate, errorMessage]` over a single value fits better as a [custom rule](#custom-rules). ## Built-in Rules Requiredness is not a rule. It is a `makeRules` option: pass `required: message` to make the field required, or omit it for an optional field. By default, Foldkit treats an empty string or empty array as missing. Whitespace and every other value, including the boolean `false`, count as present. Pass an `isEmpty` predicate to `makeRules` when your control needs a different definition of empty. To require that a checkbox is checked, use a custom rule such as `[(checked) => checked, message]`; unchecked is a present but invalid boolean value, not an absent one. Rule Description `Rule.minLength(min, message?)` Minimum character count `Rule.maxLength(max, message?)` Maximum character count `Rule.pattern(regex, message?)` Matches a regular expression `Rule.email(message?)` Valid email format `Rule.url(options?)` Valid URL format `Rule.startsWith(prefix, message?)` Begins with a prefix `Rule.endsWith(suffix, message?)` Ends with a suffix `Rule.includes(substring, message?)` Contains a substring `Rule.equals(expected, message?)` Exact string match `Rule.oneOf(values, message?)` Value is in a set of allowed strings For array-valued fields like a multi-select, validate with `Rule.minItems(min, message?)` and `Rule.maxItems(max, message?)`. A required array field already treats the empty array as missing, so reach for these when you need a specific count. ### Rules from a Schema When a value is already modeled by a Schema, a domain codec, or a refined or branded type, `Rule.fromSchema(schema, message)` turns it into a rule, so the field stays in sync with that Schema instead of duplicating its checks. It does nothing a custom rule can’t, so reach for it only when you already maintain the Schema; for plain checks the dedicated rules above are clearer. Its sweet spot is values where “valid” means “decodes”. The Schema can transform a string into a different type, like a `Calendar.CalendarDateFromIsoString` codec that parses a date the string-shaped rules can’t check, or refine and brand it, like a `Slug`. Either way the field reuses the one Schema as its rule, so the check can’t drift from the type you already maintain: ``` import { Schema as S } from 'effect' import { Calendar } from 'foldkit' import { Field, Rule, makeRules } from 'foldkit/fieldValidation' // A transform Schema: parses a string into a CalendarDate. const EventDate = Calendar.CalendarDateFromIsoString // A refinement Schema: brands a string that matches the pattern. const Slug = S.String.check(S.isPattern(/^[a-z0-9-]+$/)).pipe(S.brand('Slug')) type Slug = typeof Slug.Type // Reuse each Schema as a rule, so the rule can't drift from the Schema. const eventDateRules = makeRules({ required: 'Event date is required', rules: [Rule.fromSchema(EventDate, 'Enter a real date as YYYY-MM-DD')], }) const slugRules = makeRules({ required: 'Slug is required', rules: [Rule.fromSchema(Slug, 'Use lowercase letters, numbers, and hyphens')], }) // Each Field wraps S.String, the raw value the control holds. const Model = S.Struct({ eventDate: Field(S.String), slug: Field(S.String), }) type Model = typeof Model.Type ``` See the full [API reference](https://foldkit.dev/api-reference/field-validation) for details on every export. For a complete working example with sync validation, async server checks, and form submission gating, see the [Form example](https://foldkit.dev/example-apps/form). For sync-only validation with OutMessage context, see the [Auth example](https://github.com/foldkit/foldkit/tree/main/examples/auth/src/page/loggedOut/page/login.ts). --- Source: https://foldkit.dev/testing Section: Docs # Testing ## Story and Scene Foldkit tests at two boundaries. Story calls update directly. Scene enters through the rendered view. Neither test runs a browser or executes the Effects inside Commands, so both stay deterministic and fast. Story Scene Enters through A Message An interaction or lifecycle result Observes Model changes, Commands, and OutMessages Rendered output, Commands, Mounts, and OutMessages Best suited to Update logic, edge cases, and Command wiring User flows, view behavior, and accessibility Use both. Story proves the state machine behaves correctly. Scene proves that a person can reach that behavior through the view. Name each file for the boundary it tests: - `story.test.ts` drives update. - `scene.test.ts` drives the rendered view. - When one folder has several tests of the same kind, prefix the subject: `login.story.test.ts`. - Keep root-level Scene tests for flows that cross pages. Colocate page and Submodel tests with the code they exercise. The names stay accurate whether update and view live together or in separate files. See [Project Organization](https://foldkit.dev/patterns/project-organization) for the full layout. ## Story `story` starts from a Model, sends Messages through update, and keeps Commands as data until the test supplies their result Messages. See the [Story](https://foldkit.dev/testing/story) page for the full API. Story can test a root update or a child update in isolation. The update function is the contract at either level. ``` import { Command, given, message, model, story } from 'foldkit/story' import { expect, test } from 'vitest' test('delayed reset: count resets after the delay fires', () => { story( update, given({ count: 5 }), message(ClickedResetAfterDelay()), Command.expectExact(DelayReset), Command.resolve(DelayReset, CompletedDelayReset()), model(model => { expect(model.count).toBe(0) }), ) }) ``` ## Scene `scene` renders the view after every step. Locators find elements by role, label, placeholder, and visible text. Interactions invoke the view's event handlers, while cause-named steps supply Subscription, ManagedResource, and CustomElement results. Scene also tracks pending Commands and Mounts. See the [Scene](https://foldkit.dev/testing/scene) page for the full API. Scene can also start at the root or at a child Submodel. `withViewInputs` adapts a Submodel view that needs ViewInputs, and `expectOutMessage` checks a child's OutMessage directly. Choose the level by ownership. Test a Submodel's rendering, interactions, Commands, and OutMessages at the Submodel. Test parent folding, lifted Commands, route changes, and parent-computed ViewInputs at the root. Those behaviors cross the boundary and cannot be observed from the child. ``` import { Command, click, expect, given, inside, label, role, scene, text, type, } from 'foldkit/scene' import { test } from 'vitest' test('type a zip code, click get weather, see the forecast', () => { scene( { update, view }, given(model), type(label('Zip code'), '90210'), click(role('button', { name: 'Get Weather' })), expect(role('button', { name: 'Loading...' })).toExist(), // Instance form: locks in the zipCode the runtime captured. Command.expectExact(FetchWeather({ zipCode: '90210' })), Command.resolve( FetchWeather, SucceededFetchWeather({ weather: beverlyHillsWeather }), ), inside( role('article'), expect(text('Beverly Hills, California')).toExist(), expect(text('72\u00B0F')).toExist(), expect(text('Clear sky')).toExist(), ), ) }) ``` --- Source: https://foldkit.dev/patterns/project-organization Section: Docs # Project Organization Start a Foldkit application in one module. Split it when a feature becomes easier to understand as its own state machine. ## Starting Simple The smallest application keeps Model, Messages, init, update, and view in `main.ts`. A separate `entry.ts` creates and runs the runtime. Tests can then import `main.ts` without booting the application as a side effect. Add `story.test.ts` and `scene.test.ts` beside it. The [Counter example](https://foldkit.dev/example-apps/counter) shows this layout. ## File Layout When one file becomes hard to navigate, separate the root pieces and give each [Submodel](https://foldkit.dev/core/submodel) its own feature folder. ```text src/ +-- entry.ts Runtime bootstrap +-- main.ts App-level init +-- model.ts App-level state +-- message.ts App-level messages +-- command.ts App-level Commands +-- route.ts Route definitions +-- update.ts App-level update +-- view.ts App-level view +-- subscription.ts App-level subscriptions +-- story.test.ts Story tests for the app-level update +-- scene.test.ts Scene tests for flows that cross pages | +-- page/ | +-- index.ts Re-exports all pages | +-- home/ | | +-- index.ts Re-exports Home module | | +-- model.ts Home state | | +-- message.ts Home events | | +-- command.ts Home Commands | | +-- update.ts Home update | | +-- view.ts Home view | | +-- story.test.ts Story tests for the Home update | | +-- scene.test.ts Scene tests for the Home view | +-- products/ | +-- index.ts | +-- model.ts | +-- message.ts | +-- command.ts | +-- update.ts | +-- view.ts | +-- story.test.ts | +-- scene.test.ts | +-- domain/ +-- index.ts Re-exports domain modules +-- cart.ts Cart type + operations +-- item.ts Item type + operations ``` Each feature folder owns its Model, Messages, update, view, Commands, Subscriptions, and tests. Do not create empty files only to match the diagram. Add a file when the feature has that concern. Keep Commands beside the update that returns them. A feature that fetches its own data owns that Command instead of importing it from a root Command collection. Extract `message.ts` when a Command needs to import its result Message constructors without creating a cycle. A feature that declares Subscriptions owns `subscription.ts`. The parent lifts that record into its own Model and Message types. See [Subscription Organization](https://foldkit.dev/patterns/subscription-organization). Split a large feature again only when its own files become difficult to navigate. The [Typing Terminal room source](https://github.com/foldkit/foldkit/tree/main/packages/typing-game/client/src/page/room) has `view/` and `update/` subfolders inside one Room feature. ## Where Tests Live Colocate tests with the boundary they exercise. A feature's `story.test.ts` drives its update. Its `scene.test.ts` drives its view, using `withViewInputs` when the view requires them. Test rendering, interactions, Commands, and OutMessages inside the feature that owns them. Test at the parent when the contract involves parent-computed ViewInputs, wrapper routing, a lifted Command, or the parent's response to an OutMessage. Keep root Scene tests for flows that cross features or pages. Split several root flows by subject, such as `checkout.scene.test.ts` and `cart.scene.test.ts`. When one folder holds more than one test of a kind, prefix with the subject, like `login.story.test.ts`. Pure modules in `domain/` need neither primitive; they take ordinary Vitest tests beside them. See the [Testing](https://foldkit.dev/testing) page for the full Story and Scene reference. ## Domain Modules Put shared business concepts in `domain/`. Each module owns its Schema and pure operations. ``` // domain/cart.ts import { Array, Option, Schema } from 'effect' import { evo } from 'foldkit/struct' import { CartItem, Item } from './item' export const Cart = Schema.Array(CartItem) export type Cart = typeof Cart.Type export const addItem = (item: Item) => (cart: Cart): Cart => { const existing = Array.findFirst( cart, cartItem => cartItem.item.id === item.id, ) return Option.match(existing, { onNone: () => [...cart, { item, quantity: 1 }], onSome: () => Array.map(cart, cartItem => cartItem.item.id === item.id ? evo(cartItem, { quantity: quantity => quantity + 1 }) : cartItem, ), }) } export const removeItem = (itemId: string) => (cart: Cart): Cart => Array.filter(cart, cartItem => cartItem.item.id !== itemId) export const totalItems = (cart: Cart): number => Array.reduce(cart, 0, (total, { quantity }) => total + quantity) ``` Import the module as a namespace and call operations such as `Cart.addItem` and `Cart.removeItem`. ## Index Re-exports Use `index.ts` only as a barrel. Re-export the feature's modules from it. ``` // page/home/index.ts export * as Model from './model' export * as Message from './message' export * from './init' export * from './update' export * from './view' // page/index.ts export * as Home from './home' export * as Products from './products' // domain/index.ts export * as Cart from './cart' export * as Item from './item' ``` Consumers can then import the feature as a namespace. ``` import { Cart, Item } from './domain' import { Home, Products } from './page' // Access page modules Home.Model Home.view Home.update // Access domain modules Cart.addItem(item)(cart) Cart.totalItems(cart) ``` `Home.` exposes the feature's public surface without revealing its internal file layout. --- Source: https://foldkit.dev/react/foldkit-vs-react-side-by-side Section: Guides # Foldkit vs React: Side by Side ## Overview This comparison uses the same [pixel art editor](https://foldkit.dev/example-apps/pixel-art) in Foldkit and React. Both versions include grid editing, undo and redo, brush, fill, and eraser tools, mirror modes, localStorage persistence, PNG export, keyboard shortcuts, accessible controls, and a 32×32 grid that makes rendering work visible. The React version uses React 19.2, `useReducer`, [Headless UI](https://headlessui.com), custom Hooks, and manual memoization. The Foldkit version uses a Model, Messages, update, Commands, Subscriptions, Foldkit UI Submodels, and view memoization. This is a comparison of those two implementations. React applications can choose other state and effect architectures, and Foldkit applications can still be structured well or poorly within the framework’s constraints. The useful question is what each implementation makes explicit and what each framework makes unavoidable. React can recreate many of Foldkit’s boundaries with libraries and conventions. Foldkit begins with those boundaries and builds its Runtime, DevTools, and tests around them. That is the argument this page puts under pressure. Try them both The Foldkit version is in the [examples gallery](https://foldkit.dev/example-apps/pixel-art). The [React version source](https://github.com/foldkit/foldkit/tree/main/comparisons/pixel-art-react) is on GitHub. ## Every Way State Can Change Start with the input domain for application state. Both versions define a discriminated union and exhaustively route it through one state-transition function. ### Foldkit Message union The Foldkit application currently has 25 parent Messages: ``` const Message = defineMessageUnion({ PressedCell: { x: S.Number, y: S.Number }, EnteredCell: { x: S.Number, y: S.Number }, LeftCanvas: {}, ReleasedMouse: {}, SelectedColor: { colorIndex: PaletteIndex }, SelectedTool: { tool: Tool }, SelectedGridSize: { size: S.Number }, ToggledMirrorHorizontal: {}, ToggledMirrorVertical: {}, ClickedUndo: {}, ClickedRedo: {}, ClickedHistoryStep: { stepIndex: S.Number }, ClickedRedoStep: { stepIndex: S.Number }, ClickedClear: {}, ClickedExport: {}, SucceededExportPng: {}, FailedExportPng: { error: S.String }, GotErrorDialogMessage: { message: Dialog.Message }, GotThemeListboxMessage: { message: Listbox.Message }, GotToolRadioGroupMessage: { message: RadioGroup.Message }, GotGridSizeRadioGroupMessage: { message: RadioGroup.Message }, GotPaletteRadioGroupMessage: { message: RadioGroup.Message }, ConfirmedGridSizeChange: {}, GotGridSizeConfirmDialogMessage: { message: Dialog.Message }, CompletedSaveCanvas: {}, }) type Message = typeof Message.Type ``` This union is the complete input type for the parent update function. User events, Command results, and child Submodel Messages all enter through it. A `Got*Message` variant marks a child boundary; the child’s own Message union provides the detailed input domain one level down. Messages such as `SucceededExportPng` and `CompletedSaveCanvas` do not have to change the Model. They still record that a Command finished, and update must handle them. The input domain of update After initialization, the application Model changes only when update handles a Message. Commands and Subscriptions cannot mutate it directly. They dispatch Messages back into the same function. ### React Action type The React reducer has 19 Actions: ``` type Action = | Readonly<{ type: 'PressedCell'; x: number; y: number }> | Readonly<{ type: 'EnteredCell'; x: number; y: number }> | Readonly<{ type: 'LeftCanvas' }> | Readonly<{ type: 'ReleasedMouse' }> | Readonly<{ type: 'SelectedColor'; colorIndex: PaletteIndex }> | Readonly<{ type: 'SelectedTool'; tool: Tool }> | Readonly<{ type: 'SelectedGridSize'; size: number }> | Readonly<{ type: 'ToggledMirrorHorizontal' }> | Readonly<{ type: 'ToggledMirrorVertical' }> | Readonly<{ type: 'ClickedUndo' }> | Readonly<{ type: 'ClickedRedo' }> | Readonly<{ type: 'ClickedHistoryStep'; stepIndex: number }> | Readonly<{ type: 'ClickedRedoStep'; stepIndex: number }> | Readonly<{ type: 'ClickedClear' }> | Readonly<{ type: 'SelectedPaletteTheme'; themeIndex: number }> | Readonly<{ type: 'ExportFailed'; error: string }> | Readonly<{ type: 'DismissedErrorDialog' }> | Readonly<{ type: 'ConfirmedGridSizeChange' }> | Readonly<{ type: 'DismissedGridSizeDialog' }> ``` The Action union is the complete input type for this reducer. It is not the input domain for the whole component tree. PNG export begins in an event handler, localStorage persistence runs in an Effect, and Headless UI owns transient interaction state inside its components. The difference in counts reflects those boundaries, not missing features. Foldkit has Messages for starting export and for successful export and save completion. It also wraps Messages from two Dialogs, one Listbox, and three RadioGroups. The React reducer instead has Actions for two dialog dismissals and a palette-theme selection, while the rest of the Headless UI interaction stays inside the library. Both unions are useful indexes. The Foldkit union covers the parent Runtime channel. The React union covers the reducer channel chosen for this application. ## Declaration vs Procedure The two entry points assemble the same application in different ways. ### React App component The React `App` component initializes the reducer, derives values, runs three custom Hooks, and passes state into child components: ``` export const App = () => { const [state, dispatch] = useReducer(reducer, undefined, createInitialState) const theme = useMemo( () => currentPaletteTheme(state.paletteThemeIndex), [state.paletteThemeIndex], ) useKeyboardShortcuts(dispatch) useMouseRelease(state.isDrawing, dispatch) useLocalStorage( state.grid, state.gridSize, state.paletteThemeIndex, state.selectedColorIndex, state.isDrawing, ) const handleExport = () => exportPng(state, dispatch) const currentGrid = useMemo( () => state.isDrawing ? (state.undoStack[state.undoStack.length - 1] ?? state.grid) : state.grid, [state.isDrawing, state.undoStack, state.grid], ) return (
) } ``` The six Hooks have distinct jobs: one reducer, two memoized derived values, keyboard shortcuts, mouse release, and persistence. `Toolbar`, `Canvas`, and `HistoryPanel` receive the state slices they render plus `dispatch`. The Dialogs receive their controlled open state and dispatch. This is ordinary explicit React composition. The component tree is also where state, lifecycle, rendering, and library components meet. ### Foldkit program The Foldkit entry point supplies the Runtime with the application definitions: ``` // src/main.ts export const init: Runtime.ApplicationInit = flags => ({ model: { grid: Option.match(flags.maybeSavedCanvas, { onNone: () => createEmptyGrid(DEFAULT_GRID_SIZE), onSome: ({ grid }) => grid, }), undoStack: [], redoStack: [], tool: 'Brush', mirrorMode: 'None', isDrawing: false, maybeHoveredCell: Option.none(), errorDialog: Dialog.init({ id: 'export-error-dialog' }), themeListbox: Listbox.init({ id: 'theme-picker' }), // remaining fields elided for brevity }, }) // src/entry.ts (imports Model, Flags, flags, init, update, view, subscriptions from ./main) const application = Runtime.makeApplication({ Model, Flags, init, update, view, subscriptions, container: document.getElementById('root'), }) Runtime.run(application, { flags }) ``` `init` constructs the first Model and startup Commands. `Runtime.makeApplication` receives the Model and Flags Schemas, init, update, view, Subscriptions, and container. The Runtime dispatches Messages and executes lifecycle primitives. The Foldkit view still passes Model data to smaller view functions as parameters. Those functions do not own Hook state or lifecycle, so the Runtime assembly stays separate from the view tree. ## Complete State Ownership The two versions draw their application-state boundary differently. ### Foldkit Model (every UI component, fully exposed) The Foldkit Model describes application state with Effect Schema and uses `Option` for absent values. It also contains the Models for two Dialogs, one Listbox, and three RadioGroups: ``` import { Schema as S } from 'effect' import { Dialog, Listbox, RadioGroup } from '@foldkit/ui' 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: Dialog.Model, maybeExportError: S.Option(S.String), paletteThemeIndex: S.Number, gridSizeConfirmDialog: Dialog.Model, maybePendingGridSize: S.Option(S.Number), themeListbox: Listbox.Model, toolRadioGroup: RadioGroup.Model, gridSizeRadioGroup: RadioGroup.Model, paletteRadioGroup: RadioGroup.Model, }) ``` Those child Models expose transient interaction state such as whether a Listbox is open, its highlighted item, and its transition phase. The parent still owns selected values such as `paletteThemeIndex`; it passes the selected value into the child view and folds the child’s `Selected` OutMessage into parent state. ### React State (reducer fields, plus whatever Headless UI hides) This React implementation uses plain TypeScript types and `null` for absence: ``` type State = Readonly<{ grid: Grid undoStack: ReadonlyArray redoStack: ReadonlyArray selectedColorIndex: PaletteIndex gridSize: number tool: Tool mirrorMode: MirrorMode isDrawing: boolean hoveredCell: Position | null paletteThemeIndex: number exportError: string | null isErrorDialogOpen: boolean pendingGridSize: number | null isGridSizeDialogOpen: boolean }> ``` The reducer owns grid state, selected values, export errors, and the controlled open state for both Dialogs. Headless UI owns its transient focus, keyboard, and transition state. That state exists at runtime but is intentionally encapsulated behind the component API. React does not require this boundary. An application could use Schema, put more state in the reducer, or divide it among component Hooks, context, and external stores. Foldkit requires application and Submodel state to remain in the Model tree. ## The Complete Answer For a given Message, Foldkit update returns both the next Model and the Commands caused by that transition. The React reducer returns the next state. Effects and event-handler work are composed elsewhere. ### Foldkit update (state + side effects) The return type is `Update.Return`: ``` import { type Update } from 'foldkit' export const update = (model: Model, message: Message) => Message.match>(message, { PressedCell: ({ x, y }) => M.value(model.tool).pipe( withUpdateReturn, M.when('Brush', () => ({ model: 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 { model: nextModel, commands: [saveCanvas(nextModel)] } }), // ... ), ClickedUndo: () => Array.match(model.undoStack, { onEmpty: () => ({ model }), onNonEmpty: nonEmptyUndoStack => { const nextModel = evo(model, { grid: () => Array.lastNonEmpty(nonEmptyUndoStack), undoStack: () => Array.initNonEmpty(nonEmptyUndoStack), redoStack: Array.append(model.grid), }) return { model: nextModel, commands: [saveCanvas(nextModel)] } }, }), // ... 23 more handlers }) ``` `Message.match` requires a handler for every Message variant. `evo` preserves references for unchanged fields, which supports view memoization. A handler such as `ClickedUndo` returns the next Model and a `SaveCanvas` Command together. What update answers For any parent Message, update shows the next parent Model and the Commands caused immediately by that Message. Subscriptions and Mounts have their own declarations because their lifetimes are not caused by a single update transition. ### React reducer (state only) The reducer returns `State`: ``` export const reducer = (state: State, action: Action): State => { switch (action.type) { case 'PressedCell': { const { x, y } = action switch (state.tool) { case 'Brush': return { ...state, grid: applyBrush(state, x, y), undoStack: pushHistory(state.undoStack, state.grid), redoStack: [], isDrawing: true, } case 'Fill': return { ...state, grid: applyFill(state, x, y), undoStack: pushHistory(state.undoStack, state.grid), redoStack: [], } // ... } } case 'ClickedUndo': { if (state.undoStack.length === 0) { return state } const previousGrid = state.undoStack[state.undoStack.length - 1]! return { ...state, grid: previousGrid, undoStack: state.undoStack.slice(0, -1), redoStack: [...state.redoStack, state.grid], } } // ... 17 more cases } } ``` The reducer exhaustively describes its state transitions. Persistence is not part of that return value, so `ClickedUndo` cannot show that localStorage will also be updated. That connection appears in the dependency list of `useLocalStorage`. Export takes another route through an event handler. React permits libraries and application conventions that pair actions with Effects. This example uses standard reducer, Hook, and handler composition instead. ## Side Effects as Data Commands make event-driven side effects inspectable before they run. The pixel editor has two: `SaveCanvas` and `ExportPng`. ### Foldkit Command (effect as a named, inspectable value) Both Commands are named definitions with Schema-checked arguments and declared result Messages: ``` const SaveCanvas = Command.define('SaveCanvas', { args: { grid: Grid, gridSize: S.Number, paletteThemeIndex: S.Number, selectedColorIndex: PaletteIndex, }, messages: [CompletedSaveCanvas], execute: ({ grid, gridSize, paletteThemeIndex, selectedColorIndex }) => Effect.gen(function* () { const store = yield* KeyValueStore.KeyValueStore const data: SavedCanvas = { grid, gridSize, paletteThemeIndex, selectedColorIndex, } yield* store.set(STORAGE_KEY, S.encodeSync(SavedCanvasJsonString)(data)) return CompletedSaveCanvas() }).pipe( Effect.catch(() => Effect.succeed(CompletedSaveCanvas())), Effect.provide(BrowserKeyValueStore.layerLocalStorage), ), }) const ExportPng = Command.define('ExportPng', { args: { grid: Grid, gridSize: S.Number, paletteThemeIndex: S.Number }, messages: [SucceededExportPng, FailedExportPng], execute: ({ grid, gridSize, paletteThemeIndex }) => Effect.gen(function* () { const theme = PALETTE_THEMES[paletteThemeIndex] ?? PALETTE_THEMES[0] const canvas = document.createElement('canvas') const context = canvas.getContext('2d') if (Predicate.isNull(context)) { return yield* Effect.fail( FailedExportPng({ error: 'Canvas 2D context not available' }), ) } // ... paint each cell, then click a generated download link return SucceededExportPng() }).pipe( Effect.catchTag('FailedExportPng', error => Effect.succeed(error)), Effect.catch(() => Effect.succeed(FailedExportPng({ error: 'Failed to export image' })), ), ), }) ``` Update returns a Command value. The Runtime executes its Effect and dispatches the resulting Message. Foldkit DevTools can associate the Command with the Message and Model transition that produced it, and Story or Scene tests can inspect or resolve the same value. Effect locations in this application Event-driven work is in `command.ts`. Keyboard and mouse-release event sources are Subscriptions in `subscription.ts`. This application does not need a Mount. The primitive identifies why each effect exists. ### React useEffect (effect as an implicit reaction) The persistence Hook reacts to the state values in its dependency array: ``` const useLocalStorage = ( grid: Grid, gridSize: number, paletteThemeIndex: number, selectedColorIndex: PaletteIndex, isDrawing: boolean, ): void => { useEffect(() => { if (isDrawing) { return } try { const saved: SavedCanvas = { grid, gridSize, paletteThemeIndex, selectedColorIndex, } localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)) } catch { // Handle storage errors } }, [grid, gridSize, paletteThemeIndex, selectedColorIndex, isDrawing]) } ``` The React implementation has several effect locations. PNG export runs from `handleExport` in `App.tsx`. Persistence runs in `useLocalStorage`. Keyboard and mouse listeners run in two other custom Hooks. Headless UI manages the effects required by its components. That distribution follows React’s component and Hook model. To understand a reducer transition and its downstream effects, you read the reducer together with the Hooks and handlers that observe or initiate work. ## What Your Tests Can See The test boundary follows the production boundary in each implementation. Foldkit Story tests call update and receive both the Model and Commands. The React reducer tests call the reducer and receive state. The React suite uses component tests for behavior that lives in Effects or event handlers. ### Foldkit test (state + side effects in one story) `story` dispatches Messages and resolves the Commands returned by update: ``` test('undo restores the previous grid state', () => { story( update, given(emptyModel), message(PressedCell({ x: 0, y: 0 })), message(ReleasedMouse()), Command.resolve(SaveCanvas, CompletedSaveCanvas()), model(model => { expect(model.grid[0]?.[0]).toEqual(Option.some(0)) expect(model.undoStack).toHaveLength(1) }), message(ClickedUndo()), Command.resolve(SaveCanvas, CompletedSaveCanvas()), model(model => { expect(model.grid[0]?.[0]).toEqual(Option.none()) expect(model.undoStack).toHaveLength(0) expect(model.redoStack).toHaveLength(1) }), ) }) ``` `Command.resolve(SaveCanvas, CompletedSaveCanvas())` verifies that a matching Command is pending, supplies its result Message, and continues the state-machine test. Removing that Command from `ReleasedMouse` makes this Story fail at the resolution step. ### React test (state only) The reducer test covers the same paint and undo transitions: ``` test('undo restores the previous grid state', () => { const afterPaint = dispatch( emptyModel, { type: 'PressedCell', x: 0, y: 0 }, { type: 'ReleasedMouse' }, ) expect(afterPaint.grid[0]?.[0]).toBe(0) expect(afterPaint.undoStack).toHaveLength(1) const afterUndo = dispatch(afterPaint, { type: 'ClickedUndo' }) expect(afterUndo.grid[0]?.[0]).toBeNull() expect(afterUndo.undoStack).toHaveLength(0) expect(afterUndo.redoStack).toHaveLength(1) }) ``` It does not assert on persistence because persistence is outside the reducer. This is an appropriate unit boundary for the reducer. ### React test (side effects require mocking + DOM + async) The persistence test crosses the component boundary: ``` test('painting persists canvas to localStorage', async () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem') render() const cells = findCanvasCells() const firstCell = cells[0] // Simulate a paint stroke: mousedown on cell, then mouseup on document fireEvent.mouseDown(firstCell) fireEvent.mouseUp(document) // localStorage.setItem is called inside a useEffect, which runs // asynchronously after React finishes rendering. We have to poll for it. await vi.waitFor(() => { expect(setItemSpy).toHaveBeenCalledWith( 'pixel-art-react-canvas', expect.any(String), ) }) }) ``` It renders `App` in jsdom, simulates a stroke, spies on localStorage, and waits for the Effect. That test exercises the connection between the reducer state and `useLocalStorage`, which the reducer test cannot see. Foldkit Story React tests in this application State transition Model after Messages State after Actions Event-driven effect Inspect or resolve returned Commands Exercise the handler or Hook at component boundary Persistence assertion Resolve `SaveCanvas` Spy on localStorage and wait for the Effect Infrastructure `foldkit/story` , no DOM Vitest, React Testing Library, and jsdom Timing in examples Synchronous Command resolution `waitFor` for the Effect-based persistence test ## Interaction Testing Without a DOM [Scene](https://foldkit.dev/testing/scene) renders Foldkit virtual DOM and dispatches the Messages attached to matching elements. React Testing Library renders React components into jsdom and dispatches browser-like events. ### Foldkit Scene test (virtual DOM, synchronous) The Scene test clicks Export, resolves the resulting Commands, and dismisses the Dialog: ``` import { Message as DialogMessage } from '@foldkit/ui/dialog' test('failed export shows error dialog that can be dismissed', () => { scene( { update, view }, given(createTestModel()), // Click Export PNG. The update function returns an ExportPng Command. click(role('button', { name: 'Export PNG' })), // Resolve the Command with a failure. The update function opens // the error dialog in response. Command.resolve( ExportPng, FailedExportPng({ error: 'Canvas 2D context not available' }), ), Command.resolve(Dialog.ShowDialog, DialogMessage.CompletedShowDialog()), // The error dialog is open. Find elements by role and text content: // no CSS selectors, no test IDs, no DOM. expect(text('Export Failed')).toExist(), expect(text('Canvas 2D context not available')).toExist(), // Click the Dismiss button. Scene finds the handler on the virtual // DOM node, dispatches the Message, and feeds it through update. click(role('button', { name: 'Dismiss' })), // The update function returned a CloseDialog Command. Resolve it // the same way a story test does: synchronously, inline. Command.resolve(Dialog.CloseDialog, DialogMessage.CompletedCloseDialog()), // After the Command resolves, the dialog is gone. expect(text('Export Failed')).toBeAbsent(), ) }) ``` This test separates intent from outcome. It verifies that the click produces `ExportPng`, then chooses a `FailedExportPng` result and verifies the resulting UI. It does not execute the PNG Effect or prove that a real canvas failure becomes that Message. A separate Command test can cover that boundary when needed. ### React Testing Library (jsdom, mocking, imperative) The React test drives the component and stubs the canvas boundary: ``` test('failed export shows error dialog that can be dismissed', async () => { // Mock the canvas API so getContext returns null, simulating an // environment where export would fail vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) // Render the full component tree in jsdom render() // Click export — the side effect fires imperatively inside the component await userEvent.click(screen.getByRole('button', { name: /export png/i })) // findByText waits for the async state update expect(await screen.findByText('Export Failed')).toBeInTheDocument() expect(screen.getByText('Could not get canvas context')).toBeInTheDocument() // Click dismiss and assert the dialog is gone await userEvent.click(screen.getByRole('button', { name: /dismiss/i })) expect(screen.queryByText('Export Failed')).not.toBeInTheDocument() }) ``` This is a broader integration test. It reaches `handleExport` and the export implementation, where the mocked `getContext` failure dispatches `ExportFailed`. It then observes the Dialog through the rendered interface. The two tests make different trade-offs. Scene can assert separately that a click requested a Command and that each possible result produces the right UI. The React test covers the handler-to-browser-API path in one flow, but it needs a browser-API substitute in jsdom. Foldkit Scene React Testing Library in this example Render target Virtual DOM jsdom Queries `role()` , `text()` , `label()` `screen.getByRole()` , `findByText()` Side effects Commands inspected or resolved Handler and Effect execute Browser API Not exercised by this Scene Canvas boundary mocked Timing Synchronous in this test Async user events and `findByText` ## Streams vs Hooks Both applications listen for keyboard shortcuts and mouse release. The mouse-release listener should exist only while drawing. ### Foldkit Subscriptions The Subscription declares that lifetime from Model dependencies: ``` export const subscriptions = Subscription.make()(entry => ({ keyboard: Subscription.persistent( Stream.fromEventListener(document, 'keydown').pipe( Stream.mapEffect(handleKeyboardEvent), Stream.filter(Option.isSome), Stream.map(option => option.value), ), ), 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 keyboard stream is persistent. The mouse-release stream is active only when `isDrawing` is true. The Runtime compares Subscription dependencies after each update and scopes each Stream accordingly. ### React hooks The React custom Hooks express the same lifetime with Effects: ``` const useKeyboardShortcuts = (dispatch: React.Dispatch): void => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const isModifier = event.metaKey || event.ctrlKey const key = event.key.toLowerCase() if (isModifier && event.shiftKey && key === 'z') { event.preventDefault() dispatch({ type: 'ClickedRedo' }) return } if (isModifier && key === 'z') { event.preventDefault() dispatch({ type: 'ClickedUndo' }) return } // ... } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [dispatch]) } const useMouseRelease = ( isDrawing: boolean, dispatch: React.Dispatch, ): void => { useEffect(() => { if (!isDrawing) { return } const handleMouseUp = () => { dispatch({ type: 'ReleasedMouse' }) } document.addEventListener('mouseup', handleMouseUp) return () => document.removeEventListener('mouseup', handleMouseUp) }, [isDrawing, dispatch]) } ``` `useMouseRelease` returns without installing a listener when drawing is inactive. When active, it installs the listener and returns its cleanup. The dependency array tells React when to repeat that synchronization. The Hooks linter checks referenced dependencies; the setup function remains responsible for returning the matching cleanup. ## Your State or Theirs Both applications use controlled values for selections and Dialog visibility. They differ in where transient component interaction state lives. Foldkit UI React + Headless UI Selected values / open state Parent Model Reducer state passed through controlled props Transient interaction state Child Models inside the application Model Encapsulated inside Headless UI components Events Child Messages and OutMessages folded through parent update Callback props such as `onChange` and `onClose` Accessibility behavior Implemented by Foldkit UI Implemented by Headless UI Debugging Parent and child Models appear in Foldkit DevTools App state and component internals use React’s tools Foldkit exposes more of the component state as application data. Headless UI deliberately hides more implementation state behind its component API. Neither choice changes who owns the selected palette theme or whether a Dialog is open in these two applications. ## Rendering Performance Both implementations limit work around a performance-sensitive grid. Actual frame time depends on the browser, build, device, and interaction, so the code is more useful here than a single local profile. ### Foldkit memoization (data at the boundary) Foldkit memoizes view functions from arrays of Model-derived arguments: ``` 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, and the builder is the same object every render, so passing it // through the args never invalidates the cache. export const view = (model: Model, h: HtmlBuilder): Document => ({ title: 'Pixel Art', body: h.div( [], [ lazyHeader(headerView, [h]), lazyToolPanel(toolPanelView, [ model.mirrorMode, model.tool, model.gridSize, model.selectedColorIndex, isGridEmpty(model.grid), theme, model.themeListbox, h, ]), canvasView(model, theme, h), lazyHistoryPanel(historyPanelView, [ model.undoStack, model.redoStack, currentGrid, model.gridSize, theme, h, ]), ], ), }) ``` `createLazy` and `createKeyedLazy` compare arguments element by element. `evo` preserves references for unchanged Model fields, so panels whose inputs remain referentially equal can reuse their previous virtual DOM. ### React memoization (closures at the boundary) The checked-in React version uses `memo`, `useMemo`, and `useCallback`: ``` export const App = () => { const [state, dispatch] = useReducer(reducer, undefined, createInitialState) const theme = useMemo( () => currentPaletteTheme(state.paletteThemeIndex), [state.paletteThemeIndex], ) const handleExport = () => exportPng(state, dispatch) const currentGrid = useMemo( () => state.isDrawing ? (state.undoStack[state.undoStack.length - 1] ?? state.grid) : state.grid, [state.isDrawing, state.undoStack, state.grid], ) return (
{/* Each child is wrapped in memo() and receives dispatch + state slices */}
) } // Every component receiving state slices is wrapped in memo() const Toolbar = memo(function Toolbar({ tool, mirrorMode, dispatch, }: ToolbarProps) { // useCallback for every handler inside }) const Canvas = memo(function Canvas({ grid, gridSize, dispatch }: CanvasProps) { // useCallback for every handler inside }) const HistoryPanel = memo(function HistoryPanel({ undoStack, dispatch, }: HistoryProps) { // useCallback for every handler inside }) ``` `memo` compares props by reference. The application stabilizes derived values and handler props so memoized children can skip work. React Compiler 1.0 can generate much of this memoization for compatible components, and teams can adopt it incrementally. This comparison shows the source currently in the repository, which uses the manual forms. The compiler affects render optimization. It does not move persistence into the reducer or turn event-handler work into returned values, so the earlier state and effect boundaries remain the same. ### One layer down: per-cell rendering The 32×32 canvas contains 1,024 cells. Here is the event boundary for one cell in each implementation. ``` const rowView = ( row: ReadonlyArray, y: number, previewColor: HexColor, previewPositions: ReadonlyArray, theme: PaletteTheme, h: HtmlBuilder, ): Html => h.div( [h.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 h.div([ h.OnMouseDown(PressedCell({ x, y })), h.OnMouseEnter(EnteredCell({ x, y })), h.Style({ flex: '1', backgroundColor: displayColor }), ]) }), ) ``` The Foldkit cell attaches `PressedCell({ x, y })` and `EnteredCell({ x, y })` Message values. The event attributes dispatch those values to update. ``` const CellView = memo(function CellView({ x, y, backgroundColor, dispatch, }: Readonly<{ x: number y: number backgroundColor: string dispatch: React.Dispatch }>) { const handleMouseDown = useCallback( () => dispatch({ type: 'PressedCell', x, y }), [dispatch, x, y], ) const handleMouseEnter = useCallback( () => dispatch({ type: 'EnteredCell', x, y }), [dispatch, x, y], ) return (
) }) ``` The React cell is a memoized component. Its callbacks close over `x`, `y`, and `dispatch`, and their dependency arrays keep those values current. React Compiler can produce equivalent memoization without the handwritten wrappers when enabled. ## Guarantees React Cannot Provide Within a Foldkit application, the framework enforces several properties that React leaves to the selected state and effect architecture. A React application can recreate some of them with a reducer, store, event system, or additional tooling, but React itself does not require them. ### The Message union as total input domain The parent Message union is the total input domain of parent update. A child Submodel repeats that property behind its `Got*Message` wrapper. Every Runtime-driven Model transition therefore enters through a typed value from one of those unions. React’s Action union provides the same property for this reducer. It does not cover state internal to Headless UI or work begun directly in handlers and Effects, because those paths do not use the reducer. ### Safe evolution under type pressure Both versions can exhaustively handle a new union variant in their transition function. Foldkit extends that check across the Runtime channel because every parent state transition uses a Message. Adding a Message makes `Message.match` fail until update handles it. Exhaustiveness catches an omitted branch, not an incorrect branch or a forgotten product requirement. Tests still have to establish what the new case should do. ### Side effects as assertable values A Command has a name, arguments, result Messages, and identity in DevTools and tests. Story and Scene can assert that update returned it before choosing a result. React Effects and handlers are executable code rather than returned descriptions, so their tests observe execution or inject an application-specific abstraction. ### Time-travel that covers UI internals Foldkit DevTools records Messages and Model snapshots. Because Foldkit UI Submodels live in the Model, their interaction state participates in that history. React DevTools inspects component state, and reducer-oriented tools can add action history for application state. Headless UI’s internal Hook state is not part of the pixel editor reducer, so it does not appear in a reducer replay. ### Tests share the runtime’s pipeline Story calls update with Messages and handles the Commands update returns. Scene adds the actual Foldkit view and event attributes. The same values cross those boundaries in production and tests. The React tests also exercise production reducers and components. Their extra jsdom and mocking requirements come from the browser and Hook boundaries selected by this implementation, not from an inability to test React code. ### One place to look when the Model is wrong After init, Foldkit’s application Model is replaced only by update. A wrong Model transition therefore comes from an update handler or a helper it calls. Command and Subscription code can produce the wrong Message, but it cannot mutate the Model around update. This React application gives its reducer state the same central transition point. Debugging can also cross the reducer boundary when an Effect dispatches the wrong Action or a Headless UI interaction concerns state outside the reducer. ### No stale closures in view, update, or Subscriptions Foldkit update receives the current Model with each Message, and Subscription lifetimes are rebuilt from declared Model dependencies. The framework does not use Hook dependency arrays for either boundary. Closures still exist in application code, and [Mount](https://foldkit.dev/core/mount) arguments are intentionally captured when an element mounts. Commands also capture the arguments supplied when update creates them. Foldkit narrows where captured values matter; it does not remove JavaScript closures. ## Which Scales Better? The pixel editor shows the structures each codebase will extend. Future features would still involve design choices on both sides. ### Remote persistence In Foldkit, `ReleasedMouse` could return a `SyncCanvas` Command alongside `SaveCanvas`. In React, the application could extend the persistence Hook, add another Effect, or move synchronization behind an event-driven service. Request ordering, retries, and cancellation need explicit policy in either implementation. Foldkit keeps the decision to start a Command beside the Model transition. React lets the application choose whether that decision belongs in a handler, Effect, middleware, or data library. ### Multiplayer editing A multiplayer feature should define a wire protocol rather than send the entire UI Model. Foldkit can validate remote Messages or domain events with Schema and route accepted values through update. Local UI Submodels can remain local. The React version can validate the same protocol and dispatch reducer Actions for accepted events. Foldkit supplies the single Message pipeline as a framework constraint; React requires the application to choose and maintain that boundary. ### Animation timeline Both versions would add frames, a current index, and playback state. Foldkit can model playback as a Subscription that emits `AdvancedFrame`. React can model it with an Effect and `useEffectEvent`, which reads the current frame data without restarting the interval unless a synchronization dependency changes. The difference remains placement. Foldkit sends each tick through update. React synchronizes the timer from component state and dispatches Actions from its callback. ### Persistent undo history Foldkit can persist undo history with a Command and restore it through init or an initialization Command. The React version can extend `useLocalStorage` or add an IndexedDB Hook and initialize reducer state from the stored value. Both versions need versioning, decoding, and failure behavior for stored data. Foldkit’s Schema and Command result Messages provide built-in places for those concerns; React can use the validation and effect libraries the application selects. Both applications can grow to support these features. The difference is whether each feature has to join an existing pipeline. Foldkit grows through the same named categories: Model fields, Messages, update handlers, Commands, Subscriptions, and Submodels. React grows through components, Hooks, reducer Actions, and any additional state or effect libraries the team chooses. Foldkit’s constraint keeps paying the same dividend: a new behavior has a defined home and joins the same timeline. React preserves more freedom to choose that home, which is valuable until those choices become coordination work. ## Conclusion The pixel editor makes the trade concrete. React keeps rendering and lifecycle close to components and can adopt an enormous ecosystem around them. Its reducer provides a strong state core, while handlers, custom Hooks, and Headless UI complete the application around that core. Foldkit puts the application Model, state transitions, and event-driven Commands behind one Runtime channel. Subscriptions and Submodels follow the same typed-data approach, and Story, Scene, and DevTools operate on those values directly. Choose React when its ecosystem and freedom to select an application architecture matter more than having one imposed by the framework. Choose Foldkit when you want the framework to enforce where state transitions, effects, and lifecycles belong instead of relying on each application to establish those boundaries. That constraint is not something Foldkit asks you to tolerate. It is what the framework is for. --- Source: https://foldkit.dev/react/foldkit-vs-react-effect-atom Section: Guides # Foldkit vs React + Effect Atom ## Overview This page is for people who have already chosen Effect. In Effect 4, [Effect Atom](https://github.com/Effect-TS/effect/tree/main/packages/atom) provides reactive state primitives through `effect/unstable/reactivity`, with view bindings such as `@effect/atom-react`, `@effect/atom-solid`, and `@effect/atom-vue`. The host framework still owns rendering and components. Foldkit owns the application runtime and view layer. It renders through a virtual DOM built on [Snabbdom](https://github.com/snabbdom/snabbdom), and it includes routing, UI components, DevTools, and Story and Scene testing. Its architecture has one Model, a Message union, and an update function. Side effects return to the runtime as Commands and other lifecycle primitives. The choice is therefore larger than where to store state. React with Effect Atom is a React application with a reactive Effect state layer. A Foldkit application uses a different runtime, view model, and testing model. They are different paradigms that happen to share Effect. Related page This page assumes you are already using Effect. If you are coming from plain React, [Foldkit vs React](https://foldkit.dev/react/foldkit-vs-react-side-by-side) covers the broader architectural differences. ## What They Share Both approaches can use Effect values, typed errors, Layers, structured concurrency, and Schema validation at application boundaries. In Effect Atom, an Effect can run inside an async atom or a function atom. In Foldkit, a Command wraps an Effect and returns its result as a Message. Above that Effect foundation, their responsibilities differ. Effect Atom provides state and reactivity to a host view framework. Foldkit owns the runtime, rendering, routing, lifecycle primitives, and testing tools. ## Many Atoms vs One Model An atom is a reactive container for a value. You can create state with `Atom.make`, derive an atom from other atoms, read it with `useAtomValue`, and write it with `useAtomSet`. The registry tracks dependencies and notifies the components that read a changed atom. State is distributed by design, and that is the point. A feature can own its atoms, and a component can update any writable atom it imports. The cost of that locality is that no single value represents the state of the application and no single type lists every way it can change. Foldkit centralizes state in the [Model](https://foldkit.dev/core/model). The [Message](https://foldkit.dev/core/messages) union lists the facts the application handles, and [update](https://foldkit.dev/core/update) defines how those facts change the Model. Those are framework constraints, not conventions a team maintains by discipline. [Submodels](https://foldkit.dev/core/submodel) split a large application into smaller state machines while preserving the same parent-to-child Message flow. ## How State Changes The state models become concrete when a user adds or edits a todo. ### Effect Atom: setters at the call site A React component obtains a setter with `useAtomSet`. The setter can receive an updater closure: ``` import { Atom } from 'effect/unstable/reactivity' import { useAtomSet, useAtomValue } from '@effect/atom-react' type Filter = 'All' | 'Active' | 'Done' // State is a set of independent reactive cells. const filterAtom = Atom.make('All').pipe(Atom.keepAlive) const todosAtom = Atom.make>([]).pipe(Atom.keepAlive) // Any component can write any atom, with an inline updater closure. const AddTodoButton = () => { const setTodos = useAtomSet(todosAtom) return ( ) } const ClearDoneButton = () => { const setTodos = useAtomSet(todosAtom) return ( ) } const FilterTabs = () => { const filter = useAtomValue(filterAtom) const setFilter = useAtomSet(filterAtom) // ... each transition is an anonymous closure, scattered across components } ``` In this example, the ways `todosAtom` changes live at its setter call sites. An atom application can instead expose named write functions or writable derived atoms. That centralization is an application convention. Foldkit requires every Model transition to pass through update. ### Foldkit: one Message union, one update The Foldkit version represents the same actions as Messages: ``` import { Array, Schema as S } from 'effect' import { type Update } from 'foldkit' import { defineMessageUnion } from 'foldkit/message' // MODEL const Filter = S.Literals(['All', 'Active', 'Done']) export const Model = S.Struct({ todos: S.Array(Todo), filter: Filter, }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ AddedTodo: {}, ClearedDoneTodos: {}, SelectedFilter: { filter: Filter }, }) type Message = typeof Message.Type // UPDATE export const update = (model: Model, message: Message) => Message.match>(message, { AddedTodo: () => ({ model: evo(model, { todos: Array.append(emptyTodo()) }), }), ClearedDoneTodos: () => ({ model: evo(model, { todos: Array.filter(todo => !todo.done) }), }), SelectedFilter: ({ filter }) => ({ model: evo(model, { filter: () => filter }), }), }) ``` `AddedTodo`, `ClearedDoneTodos`, and `SelectedFilter` appear in DevTools and in Story or Scene tests. “How can the todo list change?” is answered by one Message union and one update function. `Message.match` reports every place that must handle a newly added Message variant. ## Async State Both provide a value type for in-progress Effect results, but the value lives in a different place. ### Effect Atom: AsyncResult `Atom.make` accepts an Effect directly. When the Effect needs Layer-provided services, `Atom.runtime` creates an atom runtime and `runtime.atom` runs the Effect with that context. The resulting atom contains an `AsyncResult`: `Initial`, `Success`, or `Failure`, with a `waiting` flag for refreshes. `AsyncResult.builder` renders those cases. ``` import { Cause, Effect } from 'effect' import { AsyncResult, Atom } from 'effect/unstable/reactivity' import { useAtomValue } from '@effect/atom-react' const runtime = Atom.runtime(Api.Default) // An async atom evaluates an Effect and exposes an AsyncResult. const userAtom = runtime.atom( Effect.gen(function* () { const api = yield* Api return yield* api.getUser() }), ) const UserCard = () => { const user = useAtomValue(userAtom) return AsyncResult.builder(user) .onInitial(() => ) .onFailure(cause => ) .onSuccess(user => ) .render() } ``` The Effect runs when the registry first evaluates the atom, and the registry stores the result and tracks dependencies. `Atom.family` creates keyed atoms. `Atom.swr` adds stale-time and revalidation behavior, while `AtomHttpApi` and `AtomRpc` integrate Effect clients. React bindings also provide Suspense hooks. ### Foldkit: remote state in the Model Foldkit stores remote state in the Model. [AsyncData](https://foldkit.dev/core/async-data) represents six states: `Idle`, `Loading`, `Refreshing`, `Failure`, `Stale`, and `Success`. A Command performs the request, and its result returns through update as a Message. ``` import { Effect, Schema as S } from 'effect' import { AsyncData, Command, type Update } from 'foldkit' import { defineMessageUnion } from 'foldkit/message' import { Api } from './api' // MODEL // Remote state is a value in the Model. AsyncData is the shipped six-state // union, so there is no hand-rolled loading/failure/stale union to maintain. const UserAsyncData = AsyncData.Schema(User, ApiError) export const Model = S.Struct({ user: UserAsyncData.schema, }) type Model = typeof Model.Type // MESSAGE const Message = defineMessageUnion({ ClickedLoadUser: {}, SucceededLoadUser: { user: User }, FailedLoadUser: { error: ApiError }, }) type Message = typeof Message.Type // COMMAND // Api is an Effect service; Api.Default is its layer. const FetchUser = Command.define('FetchUser', { messages: [Message.SucceededLoadUser, Message.FailedLoadUser], execute: Effect.gen(function* () { const api = yield* Api const user = yield* api.getUser() return Message.SucceededLoadUser({ user }) }).pipe( Effect.catch(error => Effect.succeed(Message.FailedLoadUser({ error }))), Effect.provide(Api.Default), ), }) // UPDATE export const update = (model: Model, message: Message) => Message.match>(message, { ClickedLoadUser: () => ({ model: evo(model, { user: () => UserAsyncData.Loading() }), commands: [FetchUser()], }), SucceededLoadUser: ({ user }) => ({ model: evo(model, { user: () => UserAsyncData.Success({ data: user }) }), }), FailedLoadUser: ({ error }) => ({ model: evo(model, { user: () => UserAsyncData.Failure({ error }) }), }), }) ``` `AsyncData` includes stale-while-revalidate and keep-stale-on-failure states. It does not provide a fetching registry or choose refresh policy. The application models a cache in the Model and decides when to run each Command. Remote data then shares the same Message timeline and tests as the rest of the Model. Effect Atom gives you more data-fetching machinery out of the box. Foldkit gives remote state no separate architectural lane. That distinction matters when remote data starts interacting with the rest of the application. ## Side Effects and Lifecycle Both can express side effects as Effect values. They differ in how an effect is connected to application state and lifetime. ### Effect Atom: effects live inside atoms `runtime.fn` creates a callable atom for a mutation. Its `reactivityKeys` can refresh atoms that subscribe to matching keys. An atom can also acquire a listener and register its cleanup with `addFinalizer`; `useAtomMount` keeps that atom mounted for the component’s lifetime. ``` import { Effect } from 'effect' import { Atom } from 'effect/unstable/reactivity' import { useAtomMount, useAtomSet } from '@effect/atom-react' // A mutation is a function atom. Reactivity keys invalidate dependents. const createTodoAtom = runtime.fn( Effect.fnUntraced(function* (text: string) { const api = yield* Api yield* api.createTodo(text) }), { reactivityKeys: ['todos'] }, ) // A global listener is an atom that wires addEventListener in its body, // then tears it down with a finalizer. const mouseUpAtom = Atom.make(get => { const onUp = () => get.setSelf(false) window.addEventListener('mouseup', onUp) get.addFinalizer(() => window.removeEventListener('mouseup', onUp)) return false }) const Canvas = () => { useAtomMount(mouseUpAtom) // keep the listener alive while this component is mounted const createTodo = useAtomSet(createTodoAtom) // ... } ``` Effects remain colocated with the atoms that perform them. Dependencies between a mutation and refreshed atoms can be declared through reactivity keys, which are runtime values rather than a TypeScript union checked for exhaustiveness. ### Foldkit: Commands and Subscriptions Foldkit selects a lifecycle primitive based on what causes the work. A [Command](https://foldkit.dev/core/commands) runs after a Message. A [Subscription](https://foldkit.dev/core/subscriptions) runs while a Model condition holds. A [Mount](https://foldkit.dev/core/mount) follows an element’s lifetime, and a [ManagedResource](https://foldkit.dev/core/managed-resources) follows Model state while exposing a stateful handle to Commands. ``` import { Effect, Schema as S, Stream } from 'effect' import { Command, Subscription } from 'foldkit' import { Api } from './api' // A side effect is a Command returned from update. It has a name, shows up // in DevTools next to the Message that produced it, and is assertable in // tests. Api is an Effect service; Api.Default is its layer. const CreateTodo = Command.define('CreateTodo', { args: { text: S.String }, messages: [SucceededCreateTodo, FailedCreateTodo], execute: ({ text }) => Effect.gen(function* () { const api = yield* Api yield* api.createTodo(text) return SucceededCreateTodo() }).pipe( Effect.provide(Api.Default), Effect.catch(() => Effect.succeed(FailedCreateTodo())), ), }) // Here the global listener becomes a Subscription: an external event source // bound to a slice of the Model. The runtime subscribes and unsubscribes as // model.isDrawing changes. No addEventListener, no cleanup, no stale closure. export const subscriptions = Subscription.make()(entry => ({ mouseRelease: entry( { isDrawing: S.Boolean }, { modelToDependencies: model => ({ isDrawing: model.isDrawing }), dependenciesToStream: ({ isDrawing }) => Stream.when( Subscription.fromEvent({ target: document, type: 'mouseup', toMessage: () => ReleasedMouse(), }), Effect.sync(() => isDrawing), ), }, ), })) ``` The difference is locational. Effect Atom colocates effects with atoms, and any atom may run one. Foldkit assigns effects to a small set of lifecycle primitives based on what causes them. The `mouseRelease` Subscription starts and stops from Model state, then emits Messages so the resulting change still passes through update. ## The View Layer Is Still React Effect Atom changes the state layer, not React’s rendering rules. Components still use hooks, closures, and React’s memoization tools. ### Effect Atom plus React This version stores the todos in one array atom. It uses `memo` and `useCallback` to avoid rendering an unchanged row when the array changes: ``` import { memo, useCallback } from 'react' import { useAtomSet } from '@effect/atom-react' // The view layer is still React: memo to skip re-renders, useCallback to keep // the handler reference stable, a dependency array you have to get right. const TodoItem = memo(({ todo }: { todo: Todo }) => { const setTodos = useAtomSet(todosAtom) const toggle = useCallback( () => setTodos(todos => todos.map(candidate => candidate.id === todo.id ? { ...candidate, done: !candidate.done } : candidate, ), ), [setTodos, todo.id], ) return (
  • {todo.text}
  • ) }) ``` Those optimizations are not required for correctness. A per-item atom could also give each row a narrower subscription. Either design still follows React’s hook and closure rules, and React Compiler can automate some memoization when the component satisfies its constraints. ### Foldkit The Foldkit item is a function that returns virtual DOM data. Its event handler dispatches a Message value: ``` import type { Html, HtmlBuilder } from 'foldkit/html' // The view is a plain function returning data. No memo, no useCallback, no // dependency array. The event is a Message value, not a closure, so there is // nothing to stabilize at the boundary. const todoItem = (todo: Todo, h: HtmlBuilder): Html => h.li( [], [ h.input([ h.Type('checkbox'), h.Checked(todo.done), h.OnClick(ClickedTodo({ id: todo.id })), ]), todo.text, ], ) ``` There is no component Hook state, dependency array, or callback identity to stabilize. When a view subtree is expensive, [view memoization](https://foldkit.dev/core/view-memoization) skips it based on Model-derived inputs. ## One Timeline vs Many Cells Every Foldkit state change is a Message processed by update. Foldkit DevTools records those Messages and the resulting Models, so it can replay the application timeline. This site runs on Foldkit; the DEV button in the bottom-right corner opens its DevTools. Effect Atom’s registry holds the current value of many cells and updates their dependents. It does not define one app-wide union of named events, so there is no equivalent built-in Message timeline to replay. An application can add its own event model or logging when that history is useful. ## Testing Foldkit’s update function is pure: given a Model and a Message, it returns the next Model and Commands. That shape supports two [testing](https://foldkit.dev/testing) tools. [Story](https://foldkit.dev/testing/story) sends Messages through update, inspects the Model, and resolves Commands by supplying their result Messages. The test can assert that a Command was returned without executing its Effect. ``` import { AsyncData } from 'foldkit' import { Command, given, message, model, story } from 'foldkit/story' import { expect, test } from 'vitest' test('loading a user: the Command fires, resolves, the Model lands on Success', () => { story( update, given({ user: AsyncData.Idle() }), message(ClickedLoadUser()), Command.expectExact(FetchUser), Command.resolve(FetchUser, SucceededLoadUser({ user: ada })), model(model => { expect(model.user).toStrictEqual(AsyncData.Success({ data: ada })) }), ) }) ``` [Scene](https://foldkit.dev/testing/scene) renders the view, finds elements by accessible role or text, dispatches events through update, and resolves Commands inline. ``` import { Command, click, expect, given, inside, role, scene, text, } from 'foldkit/scene' import { test } from 'vitest' test('click load, resolve the fetch, see the profile', () => { scene( { update, view }, given(model), click(role('button', { name: 'Load user' })), expect(text('Loading…')).toExist(), Command.expectExact(FetchUser), Command.resolve(FetchUser, SucceededLoadUser({ user: ada })), inside(role('article'), expect(text('Ada Lovelace')).toExist()), ) }) ``` An Effect Atom application uses the testing tools of its host framework. In React, a user-facing test commonly renders a component with React Testing Library and jsdom, then waits for the atom’s Effect and React render to finish: ``` import { expect, test, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' test('loading a user renders the profile', async () => { // userAtom runs an Effect that fetches the user, so the test stubs the // network boundary and renders the component tree in jsdom. vi.spyOn(globalThis, 'fetch').mockResolvedValue( Response.json({ name: 'Ada Lovelace' }), ) render() await userEvent.click(screen.getByRole('button', { name: /load user/i })) expect(screen.getByText('Loading…')).toBeInTheDocument() // The atom resolves asynchronously, so findByText has to poll until the // AsyncResult transitions to Success and React re-renders. expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument() }) ``` Atom Effects can also be tested below the component boundary. The architectural difference is that Foldkit exposes Commands as returned values. The Story can name the requested effect without running it, while the React component test observes the Effect through the atom and rendered interface. ## Scaling Complexity An Effect Atom application grows a graph of atoms. Adding an independent atom is local, derived atoms declare their dependencies, and fine-grained subscriptions limit which components update. Understanding a cross-feature change may require following atom reads, writes, and reactivity keys across files. A Foldkit application grows its Model, Message union, and update logic. Exhaustive matching keeps the transition catalog complete, but a large update function eventually needs to be divided into [Submodels](https://foldkit.dev/core/submodel). Submodel composition adds explicit parent-child wiring. The trade-off is locality versus a central index. Effect Atom favors independently composable reactive cells. Foldkit favors an explicit state machine whose transitions share one runtime path. ## AI-Assisted Development Foldkit’s closed Message unions and exhaustive updates give a coding agent a bounded list of state transitions and compile errors when a new variant is unhandled. The type system turns a new Message into a concrete to-do list. Commands, Subscriptions, and views also have distinct roles, which narrows where related code should live. Effect Atom favors feature-local atoms, so an agent instead follows imports, derived dependencies, setters, and reactivity keys. That can make a local feature compact while requiring more repository search for changes that cross several atoms. Foldkit’s [AI tools](https://foldkit.dev/ai/overview) document its framework-specific patterns for agents that do not already know them. ## Practical Trade-offs Architecture aside, several practical factors affect the choice. React + Effect Atom Foldkit Ecosystem React components, tooling, and libraries [Foldkit UI](https://foldkit.dev/ui/overview) , plus [Mount](https://foldkit.dev/core/mount) and [CustomElement](https://foldkit.dev/core/custom-element) for third-party integration Incremental adoption Add atoms to an existing React application Owns the application runtime, though [Embedding](https://foldkit.dev/core/embedding) can mount it inside another page Data fetching `AsyncResult` , SWR, Suspense, families, and Effect HTTP/RPC integration [AsyncData](https://foldkit.dev/core/async-data) for state, with fetching, caching, and refresh policy modeled through the Model and Commands Fine-grained reactivity Components subscribe to the atoms they read Top-down view evaluation with virtual DOM diffing and [view memoization](https://foldkit.dev/core/view-memoization) View model React components and JSX Typed view functions and an HTML builder DSL ## Conclusion They share Effect. Above that foundation, they choose different application architectures. Choose React with Effect Atom when you want Effect-native reactive state inside React, incremental adoption, fine-grained subscriptions, and its async atom tools. Choose Foldkit when you want the Elm Architecture to govern the whole application, including rendering, lifecycle, DevTools, and tests. Effect Atom composes a graph of reactive cells inside a host framework. Foldkit gives the application one state machine and makes the rest of its tools follow from that constraint. --- Source: https://foldkit.dev/elm/foldkit-vs-elm-side-by-side Section: Guides # Foldkit vs Elm: Side by Side ## Overview This comparison uses the same [pixel art editor](https://foldkit.dev/example-apps/pixel-art) in Foldkit and Elm. Both versions include grid drawing, undo and redo, brush, fill, and eraser tools, mirror modes, localStorage persistence, PNG export, keyboard shortcuts, and an application history panel. Foldkit applies the Elm Architecture in TypeScript on top of Effect. The familiar pieces remain: a Model, Messages, a pure update function, a view, and side effects returned for the Runtime to execute. The differences come from the host languages, their interop models, and the tools each framework builds around the architecture. Elm is the source of this architecture, and its language provides guarantees TypeScript cannot reproduce. Foldkit trades some of those guarantees for direct access to the TypeScript, Effect, browser, and npm ecosystems. Read them both The Foldkit version is in the [examples gallery](https://foldkit.dev/example-apps/pixel-art). The [Elm version source](https://github.com/foldkit/foldkit/tree/main/comparisons/pixel-art-elm) is an Elm 0.19 application with no npm dependencies. ## The Architecture You Already Know Most concepts translate directly: Elm Foldkit State `Model` Model declared with Schema Events `Msg` custom type Message Schema union Transitions `update : Msg -> Model -> ( Model, Cmd Msg )` `update(model, message): Update.Return` Side effects `Cmd Msg` Command for Message-driven work, plus other lifecycle primitives Event streams `Sub Msg` Subscription backed by an Effect Stream Boot data Flags decoded or accepted by `init` [Flags](https://foldkit.dev/core/init-and-flags) Schema supplied at boot JS interop Flags, ports, and custom elements Direct JavaScript APIs, [Mount](https://foldkit.dev/core/mount) , and CustomElement Nested state Nested Elm Architecture composition [Submodel](https://foldkit.dev/core/submodel) helpers for the same pattern ### Elm Msg The Elm application has 21 `Msg` variants: ``` 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 | ToggledThemePicker ``` ### Foldkit Message union The current Foldkit application has 25 parent Messages: ``` const Message = defineMessageUnion({ PressedCell: { x: S.Number, y: S.Number }, EnteredCell: { x: S.Number, y: S.Number }, LeftCanvas: {}, ReleasedMouse: {}, SelectedColor: { colorIndex: PaletteIndex }, SelectedTool: { tool: Tool }, SelectedGridSize: { size: S.Number }, ToggledMirrorHorizontal: {}, ToggledMirrorVertical: {}, ClickedUndo: {}, ClickedRedo: {}, ClickedHistoryStep: { stepIndex: S.Number }, ClickedRedoStep: { stepIndex: S.Number }, ClickedClear: {}, ClickedExport: {}, SucceededExportPng: {}, FailedExportPng: { error: S.String }, GotErrorDialogMessage: { message: Dialog.Message }, GotThemeListboxMessage: { message: Listbox.Message }, GotToolRadioGroupMessage: { message: RadioGroup.Message }, GotGridSizeRadioGroupMessage: { message: RadioGroup.Message }, GotPaletteRadioGroupMessage: { message: RadioGroup.Message }, ConfirmedGridSizeChange: {}, GotGridSizeConfirmDialogMessage: { message: Dialog.Message }, CompletedSaveCanvas: {}, }) type Message = typeof Message.Type ``` The count differs because the component and effect boundaries differ. Foldkit has `SucceededExportPng` and `CompletedSaveCanvas` for Command completion, plus six `Got*Message` wrappers for two Dialogs, one Listbox, and three RadioGroups. The Elm version hand-rolls those controls and represents their application-facing events with four direct Msgs: `ToggledThemePicker`, `SelectedPaletteTheme`, `DismissedErrorDialog`, and `DismissedGridSizeDialog`. Both unions are the total input domain of their application update functions. Foldkit’s child wrapper Messages lead to another Message union and update function inside each Submodel. Elm custom-type values and Foldkit Schema values both have runtime tags. Schema also supplies runtime decoding and encoding when a Message is deliberately used at an external boundary. That does not make an application Message union a wire protocol automatically. A network boundary still needs an explicit Schema and compatibility policy. ## The Update Function The update functions have the same shape. ### Elm update ``` 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) ) ``` ### Foldkit update ``` import { type Update } from 'foldkit' export const update = (model: Model, message: Message) => Message.match>(message, { PressedCell: ({ x, y }) => M.value(model.tool).pipe( withUpdateReturn, M.when('Brush', () => ({ model: 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 { model: nextModel, commands: [saveCanvas(nextModel)] } }), // ... ), ClickedUndo: () => Array.match(model.undoStack, { onEmpty: () => ({ model }), onNonEmpty: nonEmptyUndoStack => { const nextModel = evo(model, { grid: () => Array.lastNonEmpty(nonEmptyUndoStack), undoStack: () => Array.initNonEmpty(nonEmptyUndoStack), redoStack: Array.append(model.grid), }) return { model: nextModel, commands: [saveCanvas(nextModel)] } }, }), // ... 23 more handlers }) ``` `case msg of` becomes `Message.match`. Elm record updates become `evo` transformations. `( model, Cmd.none )` becomes `{ model }`. Elm enforces exhaustive pattern matching as part of the language. Foldkit obtains the same compile-time failure at a match written with `Message.match`. That is the required Foldkit update style, but TypeScript itself does not prevent someone from writing a non-exhaustive alternative. Elm record updates and `evo` both preserve references to unchanged nested values. The rendering section shows how each application uses that reference stability. ## The Model: Custom Types vs Schema ### Elm Model (type alias and custom types) The Elm Model uses custom types and `Maybe`: ``` 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 } ``` This hand-rolled UI stores Dialog visibility through the presence of `exportError` and `pendingGridSize`. The theme picker uses a separate `isThemePickerOpen` field. ### Foldkit Model (Schema struct) The Foldkit Model uses Effect Schema, `Option`, and child Models for its stateful Foldkit UI controls: ``` import { Schema as S } from 'effect' import { Dialog, Listbox, RadioGroup } from '@foldkit/ui' 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: Dialog.Model, maybeExportError: S.Option(S.String), paletteThemeIndex: S.Number, gridSizeConfirmDialog: Dialog.Model, maybePendingGridSize: S.Option(S.Number), themeListbox: Listbox.Model, toolRadioGroup: RadioGroup.Model, gridSizeRadioGroup: RadioGroup.Model, paletteRadioGroup: RadioGroup.Model, }) ``` A Schema exists at runtime as well as in TypeScript. Foldkit can use it to validate flags and persisted values, encode selected data, and describe Models to framework tooling. The child component Models expose interaction state that the Elm application implements directly in its parent Model and views. Elm’s type system is sound and its custom types are compact. TypeScript is intentionally less strict, while Schema adds runtime boundary tools that a plain TypeScript type does not have. ## Ports vs Commands The pixel editor saves to localStorage and exports a PNG. The Elm implementation crosses into JavaScript for both operations. The Foldkit implementation performs them in Commands. ### Elm ports (the effect lives in JavaScript) The Elm side declares outgoing and incoming 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 ) ``` The JavaScript side subscribes to them 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', ) } }) ``` The port declaration gives Elm a typed interface. The JavaScript subscriber remains outside the Elm compiler, so a renamed payload field or a missing `send` call is not checked against the Elm source. A JavaScript exception can still affect the host page; the boundary protects Elm code from directly calling arbitrary JavaScript, not the entire page from JavaScript failures. The export failure needs an incoming port so JavaScript can send `FailedExportPng` back to Elm. Saving is fire-and-forget in this application. ### Foldkit Commands (the effect lives with the app) Foldkit runs in the JavaScript ecosystem, so its Commands can use browser APIs and JavaScript libraries directly: ``` const SaveCanvas = Command.define('SaveCanvas', { args: { grid: Grid, gridSize: S.Number, paletteThemeIndex: S.Number, selectedColorIndex: PaletteIndex, }, messages: [CompletedSaveCanvas], execute: ({ grid, gridSize, paletteThemeIndex, selectedColorIndex }) => Effect.gen(function* () { const store = yield* KeyValueStore.KeyValueStore const data: SavedCanvas = { grid, gridSize, paletteThemeIndex, selectedColorIndex, } yield* store.set(STORAGE_KEY, S.encodeSync(SavedCanvasJsonString)(data)) return CompletedSaveCanvas() }).pipe( Effect.catch(() => Effect.succeed(CompletedSaveCanvas())), Effect.provide(BrowserKeyValueStore.layerLocalStorage), ), }) const ExportPng = Command.define('ExportPng', { args: { grid: Grid, gridSize: S.Number, paletteThemeIndex: S.Number }, messages: [SucceededExportPng, FailedExportPng], execute: ({ grid, gridSize, paletteThemeIndex }) => Effect.gen(function* () { const theme = PALETTE_THEMES[paletteThemeIndex] ?? PALETTE_THEMES[0] const canvas = document.createElement('canvas') const context = canvas.getContext('2d') if (Predicate.isNull(context)) { return yield* Effect.fail( FailedExportPng({ error: 'Canvas 2D context not available' }), ) } // ... paint each cell, then click a generated download link return SucceededExportPng() }).pipe( Effect.catchTag('FailedExportPng', error => Effect.succeed(error)), Effect.catch(() => Effect.succeed(FailedExportPng({ error: 'Failed to export image' })), ), ), }) ``` Each Command declares its arguments and result Messages. Its Effect can use typed failures and recovery operators before returning a Message to update. The application still needs to choose meaningful error behavior. Here `ExportPng` reports failure, while `SaveCanvas` intentionally converts storage failure into the same completion Message as success. The boundary trade-off Elm prevents application code from calling arbitrary JavaScript and makes interop explicit through ports or custom elements. Foldkit keeps update pure by convention and framework design, while Command bodies can call the host ecosystem directly. TypeScript cannot enforce Elm’s purity boundary. ## JSON: Decoders vs Schema Both applications restore a saved canvas from boot flags and persist it as JSON. ### Elm decoders and encoders Elm defines the type, decoder, and encoder separately: ``` 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 ) ] ``` The compiler checks the values each function produces, but the decoder and encoder use independent string field names. A mismatch between `"gridSize"` and `"gridsize"` can compile. ### Foldkit Schema (one definition, both directions) Foldkit derives both directions from one `SavedCanvas` Schema: ``` // 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 = 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) ``` The Schema centralizes field names and value constraints. Encoding and decoding therefore evolve from the same definition. Version migrations and fallback behavior still belong to the application. ## Subscriptions Both frameworks derive external event streams from Model state. The mouse-release listener exists only while the user is drawing. ### Elm subscriptions ``` 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. ``` ### Foldkit Subscriptions ``` export const subscriptions = Subscription.make()(entry => ({ keyboard: Subscription.persistent( Stream.fromEventListener(document, 'keydown').pipe( Stream.mapEffect(handleKeyboardEvent), Stream.filter(Option.isSome), Stream.map(option => option.value), ), ), 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), ), }, ), })) ``` Elm’s `Sub.batch` and Foldkit’s Subscription registry both describe the active set after each state transition. The runtime handles setup and teardown. Elm uses `Browser.Events` for keyboard and mouse input and an incoming port for export failure. Foldkit Subscriptions use Effect Streams, so the application can construct a Stream from browser APIs or a JavaScript client directly. Export failure does not need a Subscription because it is already a declared Command result. ## Rendering Performance Both implementations use reference-based memoization around the grid. Actual frame time depends on the production build, browser, and device, so this section compares the mechanisms rather than claiming a universal winner. ### Elm Html.Lazy and Html.Keyed ``` 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 ) ) ) ``` ### Foldkit createLazy and keyed ``` 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, and the builder is the same object every render, so passing it // through the args never invalidates the cache. export const view = (model: Model, h: HtmlBuilder): Document => ({ title: 'Pixel Art', body: h.div( [], [ lazyHeader(headerView, [h]), lazyToolPanel(toolPanelView, [ model.mirrorMode, model.tool, model.gridSize, model.selectedColorIndex, isGridEmpty(model.grid), theme, model.themeListbox, h, ]), canvasView(model, theme, h), lazyHistoryPanel(historyPanelView, [ model.undoStack, model.redoStack, currentGrid, model.gridSize, theme, h, ]), ], ), }) ``` `Html.Lazy.lazy` and `createLazy` reuse a previous rendered value when their function inputs remain referentially equal. Elm provides arity-specific helpers such as `lazy` and `lazy5`. Foldkit creates a lazy wrapper at module scope and passes an argument array. `createKeyedLazy` retains a separate cache for each stable key. ### The cell view, twice Both cell views attach Message values to event attributes: ``` 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, y: number, previewColor: HexColor, previewPositions: ReadonlyArray, theme: PaletteTheme, h: HtmlBuilder, ): Html => h.div( [h.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 h.div([ h.OnMouseDown(PressedCell({ x, y })), h.OnMouseEnter(EnteredCell({ x, y })), h.Style({ flex: '1', backgroundColor: displayColor }), ]) }), ) ``` Neither view needs a component instance or a memoized event-handler closure for each cell. The coordinates are stored in the `Msg` or Message value dispatched by the event. ## UI Components The Elm version implements its Dialogs, RadioGroups, switches, and theme picker in the application. That keeps their state and events visible, but the application also owns their ARIA attributes, keyboard behavior, focus behavior, and transitions. The Foldkit version uses [Foldkit UI](https://foldkit.dev/ui/overview). Its Dialogs, RadioGroups, and Listbox are Submodels, while Switch is a controlled render helper. Selected values remain in the parent Model. Each stateful component reports changes through OutMessages that the parent folds into its own update. Elm application Foldkit application Dialog, RadioGroup, Switch, Listbox Implemented in the application Dialog, Listbox, and RadioGroup Submodels; controlled Switch helper Accessibility behavior Implemented and tested by the application Implemented and tested by Foldkit UI Selected values Parent Model Parent Model Transient interaction state Parent Model and view logic Child Models in the application Model Composition Nested architecture written by the app [Submodel](https://foldkit.dev/core/submodel) helpers standardize parent-child delegation The comparison is between the two checked-in applications, not the entire Elm package ecosystem. An Elm application can use community UI packages or organize nested state differently. ## Testing Both update functions are pure and easy to call directly. Their effect values differ. ### Elm update test (pure, but the Cmd is opaque) ``` 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 ] afterUndo ``` `Cmd Msg` is opaque, so a direct `elm-test` unit test cannot compare or pattern-match the Command returned by update. The underscores discard it. Removing the save Command from `ReleasedMouse` would not fail this particular unit test. Program-level tools such as [elm-program-test](https://package.elm-lang.org/packages/avh4/elm-program-test/latest/) provide a higher-level way to simulate supported effects and interactions. That is a different test boundary from inspecting a `Cmd` value directly. ### Foldkit Story test (Commands are assertable values) ``` test('undo restores the previous grid state', () => { story( update, given(emptyModel), message(PressedCell({ x: 0, y: 0 })), message(ReleasedMouse()), Command.resolve(SaveCanvas, CompletedSaveCanvas()), model(model => { expect(model.grid[0]?.[0]).toEqual(Option.some(0)) expect(model.undoStack).toHaveLength(1) }), message(ClickedUndo()), Command.resolve(SaveCanvas, CompletedSaveCanvas()), model(model => { expect(model.grid[0]?.[0]).toEqual(Option.none()) expect(model.undoStack).toHaveLength(0) expect(model.redoStack).toHaveLength(1) }), ) }) ``` A [Story](https://foldkit.dev/testing/story) receives the named Commands returned by update. `Command.resolve` verifies that `SaveCanvas` is pending, supplies `CompletedSaveCanvas`, and dispatches that result Message. Removing the Command makes this Story fail at the resolution step. [Scene](https://foldkit.dev/testing/scene) adds interaction through Foldkit virtual DOM. It can query by accessible role, label, or text without starting jsdom. ## What You Give Up Moving from Elm to Foldkit gives up language-level constraints. **Enforced purity.** Elm code cannot call `Date.now()`, mutate an object, or perform I/O from update. TypeScript can. Foldkit’s architecture, conventions, and tests make the intended boundary visible, but they do not make an impure update impossible to write. **Elm’s runtime guarantees.** Elm models failure as data and prevents the ordinary null, undefined, and non-exhaustive failures common in JavaScript. Foldkit uses Schema, Effect, and explicit failure Messages, but TypeScript and npm dependencies can still throw or produce invalid values. **A smaller language and package surface.** Elm has one language, formatter, package manager, and constrained package API. TypeScript plus Effect and browser libraries has a larger set of concepts and more choices. **A smaller default runtime footprint.** Optimized Elm output is often compact. A Foldkit application includes Foldkit and Effect. The actual production size depends on the application and should be measured from the two builds being considered. ## What You Gain Foldkit gains direct access to the host ecosystem and additional framework tools. **JavaScript and npm access.** Browser APIs and compatible JavaScript packages can be imported into a Command, Mount, Subscription, ManagedResource, or CustomElement without a port layer. **TypeScript integration.** An embedded Foldkit program can share modules and types with its TypeScript host. Elm also embeds cleanly, but host communication crosses flags, ports, or custom elements. **Schema codecs.** One definition can provide the TypeScript type, runtime validation, and encoding and decoding for an external boundary. **Inspectable Commands.** Foldkit DevTools records named Commands beside the Messages that produced them, and Story and Scene tests can assert on the same values. **Effect services and control flow.** Commands can compose retries, timeouts, concurrency, resources, Layers, and typed failures from Effect. **First-party UI Submodels.** Foldkit UI supplies accessible components built with the same Model, Message, and update architecture as the application. ## Conclusion Elm and Foldkit share the application model, so the choice turns on the host environment and the guarantees you need. Choose Elm when its language, compiler, package constraints, and interop model fit the application. Those constraints provide purity and refactoring guarantees that a TypeScript framework cannot reproduce. Choose Foldkit when the application needs to remain in TypeScript, integrate directly with JavaScript libraries, or use Effect services while retaining the Elm Architecture. Foldkit standardizes that architecture and adds Schema, inspectable Commands, Submodels, DevTools, and testing tools around it. The [Elm source](https://github.com/foldkit/foldkit/tree/main/comparisons/pixel-art-elm) and [Foldkit source](https://github.com/foldkit/foldkit/tree/main/examples/pixel-art) remain recognizably the same kind of program. Their differences show which guarantees come from Elm the language and which structures Foldkit recreates in TypeScript. --- Source: https://foldkit.dev/core/architecture Section: Core Concepts # Architecture ## One State Tree In most TypeScript UI frameworks, each component manages its own state and effects. Foldkit keeps application state in one `Model` and sends every change through the same loop. This pattern is called [The Elm Architecture](https://guide.elm-lang.org/architecture/). You don’t need to know Elm to use it. Foldkit adapts the pattern for TypeScript and Effect so state transitions stay explicit and traceable. ## The Loop Every Foldkit app repeats the same cycle: 1. Something happens, and a `Message` records that fact. 2. `update` receives the current `Model` and the Message, then returns the next Model and any `Command`s to execute. 3. `view` renders the next Model as HTML, and the runtime executes the Commands. 4. User events and effect results produce more Messages, and the cycle begins again. The complete cycle looks like this: ``` +------> update -> Commands -----------+ | | | | v | | Model -> Subscriptions --------+ | | | | +-> ManagedResources --------+ | | | | v | | view -> Mounts ----------------+ | | | | v | | Browser -> events -------------+ | v | Runtime | | | v +<--------------------------------- Message ``` Five sources report through the Runtime: Commands, the Browser, Mounts, Subscriptions, and ManagedResources. When one produces a Message, the Runtime dispatches it back into `update`. ### Where Messages Come From - **Browser:** interactions with the rendered view, such as clicks and keypresses, produce Messages directly. - **Commands:** one-shot side effects such as HTTP requests, focus operations, `localStorage` writes, and navigation calls. The runtime executes each Command and sends its declared result back as a Message. Every Command has a name that appears in [DevTools](https://foldkit.dev/core/devtools), [tests](https://foldkit.dev/testing), and tracing. - **Mount:** imperative work scoped to the lifetime of an element in the live DOM. For example: portaling an overlay, attaching an observer, or handing an element to a third-party library. `Mount.define` runs an Effect that emits one Message at acquire. `Mount.defineStream` runs a Stream of Messages from listeners or observers. The runtime dispatches those results and runs the paired cleanup when the element unmounts. - **Subscriptions:** scoped Streams gated by a slice of the Model. The runtime keeps a Subscription alive while that slice holds its value, then starts a fresh scope when the value changes. A Subscription often turns an external source, such as timer ticks, `WebSocket` frames, or system theme changes, into Messages. It can also emit no Messages and maintain DOM state for its lifetime, such as setting `user-select: none` while a drag is active. - **ManagedResources:** stateful handles, such as a camera stream, a `WebSocket` connection, or a Web Worker pool, that exist while a slice of the Model holds a particular value. The runtime acquires and releases the handle and dispatches Messages for each lifecycle transition. Commands and Subscriptions can use the typed handle while it is live and receive `ResourceNotAvailable` rather than crashing when it is not. Resources sit beneath the loop instead of feeding it directly. They are app-lifetime dependencies such as an `RpcClient`, an analytics client, or a background compute worker. The runtime shares them with Commands, Subscriptions, and startup Flags, but Resources do not produce Messages themselves. These sources never mutate the Model. They report what happened with a Message, and only `update` decides the next state. If you want to know how the app reached its current state, follow the Messages. ## Definitions Use this table as a reference after you understand the loop: Concept Definition Model The single data structure that holds the entire application state. Message A fact about something that happened, such as a button click, a keypress, or a successful request with a payload. update A pure function that receives the current Model and a Message, then returns the next Model and any Commands to execute. view A pure function that renders the Model as HTML. Its event handlers construct Messages. Command A description of a one-shot side effect. The runtime executes it and sends the result back as one of its declared Messages. Mount Imperative work scoped to a live DOM element. It emits Messages through an Effect or Stream and cleans up when the element unmounts. Subscription A scoped Stream gated by a slice of the Model. The runtime restarts its scope when that slice changes. Resource An app-lifetime singleton shared with Commands, Subscriptions, and startup Flags. It is a dependency, not a Message source. ManagedResource A stateful handle scoped to a slice of the Model. The runtime manages its lifecycle, and Commands and Subscriptions can use it while it is live. Runtime The Foldkit engine that executes Commands, runs Subscriptions, manages Mount and resource lifecycles, and routes Messages back into update. Submodel A self-contained Model, Message, update, and Commands that a parent embeds and delegates to. A child can surface high-level facts to its parent through the optional `outMessage` field returned by update. ## The Restaurant Analogy Think of a Foldkit app like a restaurant. The waiter keeps a notebook: a running picture of everything happening right now. Table 3 ordered the salmon. Table 5 is waiting for dessert. When something happens (a customer flags the waiter, the kitchen rings the bell), the waiter hears about it, updates their notebook, and maybe writes a slip for the kitchen. The waiter doesn’t cook the salmon. They hand the slip to the kitchen, and the kitchen reports back when it’s done. Messages work the same way. “Table 3 asked for the check” is a fact given to the waiter, not an instruction. The waiter decides what to do: maybe bring the check immediately, maybe offer dessert first. The message stays the same either way. The restaurant analogy Use the analogy to remember who knows the state and who performs effects. The definitions above remain the literal contracts. Foldkit Restaurant Model The waiter’s notebook: the current state of everything Message Something that happens: “table 3 asked for the check” update The waiter: hears what happened, updates the notebook, maybe writes a slip view What the customers actually see: plates on the table, the check arriving Command A slip for the kitchen: “prepare the salmon” Mount Tableside flambé: rolled out to a specific table the moment its dish arrives, rolled away when the plate is cleared Subscription A standing order: “keep the coffee coming for table 5” Resource Kitchen equipment: the oven, the stand mixer, the deep fryer. Turned on when the kitchen opens and available to every dish. ManagedResource A specialty station: set up when the menu features the seafood special, broken down when the special ends Runtime The kitchen: does the work, reports back when done That’s the architecture in the abstract. The next page shows a complete counter application: the core of the loop (a Model, Messages, `update`, `init`, and `view`) wired together and running. --- Source: https://foldkit.dev/core/counter-example Section: Core Concepts # A Simple Counter Example ## See the Whole Loop This counter puts the core loop from [Architecture](https://foldkit.dev/core/architecture) into one small application. Its Model holds the count. Its Messages record button clicks. Its update function decides the next count, and its view renders the result. The example uses two files. `src/main.ts` holds the pure application definitions: Model, Messages, update, init, and view. Larger applications can split those definitions into focused modules. `src/entry.ts` remains the runtime boundary, so tests can import the application without starting it as a side effect. ``` import { Schema as S } from 'effect' import { Runtime, type Update } from 'foldkit' import type { Document, HtmlBuilder } from 'foldkit/html' import { defineMessageUnion } from 'foldkit/message' import { evo } from 'foldkit/struct' // MODEL export const Model = S.Struct({ count: S.Number, }) export type Model = typeof Model.Type // MESSAGE export const Message = defineMessageUnion({ ClickedDecrement: {}, ClickedIncrement: {}, ClickedReset: {}, }) export type Message = typeof Message.Type // UPDATE export const update = (model: Model, message: Message) => Message.match>(message, { ClickedDecrement: () => ({ model: evo(model, { count: count => count - 1 }), }), ClickedIncrement: () => ({ model: evo(model, { count: count => count + 1 }), }), ClickedReset: () => ({ model: evo(model, { count: () => 0 }) }), }) // INIT export const init: Runtime.ApplicationInit = () => ({ model: { count: 0 }, }) // VIEW export const view = (model: Model, h: HtmlBuilder): Document => ({ title: `Counter: ${model.count}`, body: h.div( [ h.Class( 'min-h-screen bg-white flex flex-col items-center justify-center gap-6 p-6', ), ], [ h.div( [h.Class('text-6xl font-bold text-gray-800')], [model.count.toString()], ), h.div( [h.Class('flex flex-wrap justify-center gap-4')], [ h.button( [h.OnClick(Message.ClickedDecrement()), h.Class(buttonStyle)], ['-'], ), h.button( [h.OnClick(Message.ClickedReset()), h.Class(buttonStyle)], ['Reset'], ), h.button( [h.OnClick(Message.ClickedIncrement()), h.Class(buttonStyle)], ['+'], ), ], ), ], ), }) // STYLE const buttonStyle = 'bg-black text-white hover:bg-gray-700 px-4 py-2 transition' ``` The entry imports those definitions and passes them to `Runtime.makeApplication`. `Runtime.run` then starts the application in the selected container. ``` import { Runtime } from 'foldkit' import { Model, init, update, view } from './main' const application = Runtime.makeApplication({ Model, init, update, view, container: document.getElementById('root'), }) Runtime.run(application) ``` Read the example once for its shape. The next four pages examine the [Model](https://foldkit.dev/core/model), [Messages](https://foldkit.dev/core/messages), [update](https://foldkit.dev/core/update), and [view](https://foldkit.dev/core/view) in order. Later pages extend the same counter with a delayed reset, automatic counting, and saved state to introduce side effects and ongoing work. Start with the Model, the single data structure that describes the application right now. --- Source: https://foldkit.dev/core/model Section: Core Concepts # Model ## One State Tree The Model is the complete application state in one immutable data structure. Everything the application can be at a moment lives here, rather than being divided between component-local and global stores. In the [restaurant analogy](https://foldkit.dev/core/architecture#the-restaurant-analogy), this is the waiter's notebook. The analogy is a memory aid; the literal contract is one state tree that every transition receives and returns. The counter defines its Model with [Effect Schema](https://effect.website/docs/schema/introduction/): ``` import { Schema as S } from 'effect' // MODEL const Model = S.Struct({ count: S.Number, }) type Model = typeof Model.Type ``` `S.Struct` creates the runtime Schema. `typeof Model.Type` derives the TypeScript type from that same definition, so the runtime and compiler agree on the Model’s shape. That runtime value matters because TypeScript types disappear after compilation. Foldkit uses the Model Schema to encode and decode state preserved across hot updates. The same Schema can validate unknown data at application boundaries. The counter starts with one field. When automatic counting becomes part of the application state, the Model grows to record it: ``` import { Schema as S } from 'effect' // When the counter gains auto-counting, // the Model grows to hold new state: const Model = S.Struct({ count: S.Number, isAutoCounting: S.Boolean, }) type Model = typeof Model.Type ``` Model the application, not the screen Store facts the application needs to remember. Values used only to render one frame can usually be derived in view instead of becoming another Model field. The Model describes the current state. Every change begins with a [Message](https://foldkit.dev/core/messages), a fact about something that happened. --- Source: https://foldkit.dev/core/messages Section: Core Concepts # Messages ## Facts, Not Instructions A Message records something that happened in the application. It does not prescribe the response. The update function decides what the fact means for the current Model. `ClickedIncrement` does not mean “add one.” It records that the user clicked the increment button. In this counter, update adds one. A later version may return a Command that obtains the next value elsewhere. The Message remains a stable account of the event. The counter has three Messages: ``` import { Schema as S } from 'effect' import { defineMessageUnion } from 'foldkit/message' // MESSAGE // defineMessageUnion() declares the union and its callable constructors together const Message = defineMessageUnion({ ClickedDecrement: {}, ClickedIncrement: {}, ClickedReset: {}, }) type Message = typeof Message.Type ``` Messages use verb-first, past-tense names such as `ClickedIncrement`, not `Increment` or `ADD_COUNT`. Prefixes make their causes easy to scan. `Clicked*` records clicks, and `Updated*` records input changes. Command results use `Succeeded*` or `Failed*` when the distinction matters, and `Completed*` otherwise. `Got*` is reserved for results lifted from a child [Submodel](https://foldkit.dev/core/submodel). The `defineMessageUnion()` helper declares the whole union in one place. Each key becomes a callable constructor on the union, so `Message.ClickedIncrement()` creates the value and `Message.match(message, handlers)` handles every variant exhaustively. Do not destructure the constructors. Keeping `Message` or `OutMessage` at the call site makes the owning domain explicit. Name the cause A Message says what happened, not what update intends to do next. That keeps the same fact useful when the application’s response changes. Messages describe what happened. The [update function](https://foldkit.dev/core/update) defines every resulting state transition. --- Source: https://foldkit.dev/core/update Section: Core Concepts # Update ## One Function Defines Every Transition The update function receives the current Model and a Message, then returns the next Model and any Commands for the runtime to execute. It is the only place application state changes. Update is pure. Given the same Model and Message, it returns the same result. It does not mutate state, call browser APIs, start timers, or make requests. That makes a transition direct to test: pass in the inputs and assert on the returned values. Use `Message.match` to handle the Message union. If you add a Message and omit its branch, TypeScript reports the missing case. No `default` branch silently absorbs a new variant. Use [Effect's `Match`](https://effect.website/docs/code-style/pattern-matching/) for other tagged unions, partial matches, fallbacks, and one handler shared across several tags. ``` import { type Update } from 'foldkit' import { evo } from 'foldkit/struct' // UPDATE const update = (model: Model, message: Message) => Message.match>(message, { ClickedDecrement: () => ({ model: evo(model, { count: count => count - 1 }), }), ClickedIncrement: () => ({ model: evo(model, { count: count => count + 1 }), }), ClickedReset: () => ({ model: evo(model, { count: () => 0 }) }), }) ``` Each branch describes one transition. `ClickedDecrement` and `ClickedIncrement` transform the current count. `ClickedReset` replaces it with zero. This version of the counter has no side effects, so all three omit `commands`. The branches build their next Model with [evo](https://foldkit.dev/best-practices/immutability#immutable-updates). Each named field receives a function from its current value to its next value. Omitted fields keep their existing values and references, so the same update style continues to work as the Model grows. Update returns a record containing the next Model and, when needed, an array of Commands. A Command describes one side effect, such as an HTTP request, timer, or browser API call. The [Commands](https://foldkit.dev/core/commands) page adds a delayed reset and puts the optional `commands` field to work. ## Returning Commands Return Commands beside the next Model from the Message branch that requests the work: ``` import { type Update } from 'foldkit' import { evo } from 'foldkit/struct' const update = (model: Model, message: Message) => Message.match>(message, { ClickedIncrement: () => { const nextCount = model.count + 1 return { model: evo(model, { count: () => nextCount }), commands: [PersistCount({ count: nextCount })], } }, CompletedPersistCount: () => ({ model }), }) ``` `ClickedIncrement` changes the count and asks the runtime to persist it. `CompletedPersistCount` records that the Command finished, but it has no more work to request, so that branch omits `commands`. An update, init, boot, or component helper that statically creates no Commands omits `commands`. When it computes a Commands collection, it returns that collection directly without checking whether it is empty. The [`foldkit/no-empty-commands-array`](https://foldkit.dev/tooling/oxlint-plugin#no-empty-commands-array) lint rule rejects only a literal `commands: []` property. ## Composing Results ### Keeping Results Together Keep an update-like result attached to the operation that produced it. Name the value after the operation and use dot access: ``` const homeInit = Home.init() return { model: { home: homeInit.model }, commands: Command.mapMessages(homeInit.commands, message => Message.GotHomeMessage({ message }), ), } ``` The same rule applies when a test consumes an update result: ``` const formSubmit = update(model, Message.SubmittedForm()) expect(formSubmit.model.status).toBe('Submitting') expect(formSubmit.commands ?? []).toHaveLength(1) ``` When the operation name collides with the function, use a trailing underscore such as `init_`. Do not destructure or rename `model`, `commands`, or `outMessage`. Dot access does not make an OutMessage impossible to ignore. It keeps the operation and its returned values visibly connected. Pass optional Commands directly to APIs that accept them, including `Command.mapMessages`. Use `result.commands ?? []` only when the next operation requires an array for spreading, concatenating, execution, or an assertion. ### Composing Update Steps TypeScript rejects this manual composition when the enclosing update returns `Update.Return`: ``` const dialogOpen = openDialog(model) return { model: evo(dialogOpen.model, { isSubmitting: () => false }), // Type error: with exactOptionalPropertyTypes, this property must be // omitted when dialogOpen.commands is undefined. commands: dialogOpen.commands, } ``` Every Foldkit template enables `exactOptionalPropertyTypes`. With that setting, the optional `commands` property may be absent. When the property is present, it must contain Commands. `dialogOpen.commands` has the type `Update.Commands | undefined`, so TypeScript rejects `commands: dialogOpen.commands`. This error often points to update results being composed by hand. When both operations update the same Model, express them as Steps and compose them with `Update.combine`: ``` return Update.combine(model, [ openDialog, stepModel => ({ model: evo(stepModel, { isSubmitting: () => false }), }), ]) ``` Manual unpacking of a child result usually means the site should use `Update.foldChild` or `Update.foldChildStep`. Use `Update.combine` when two or more operations transform the same Model and a later Step should receive the Model produced by an earlier Step. Name that parameter `stepModel` when an inline Step needs it: ``` return Update.combine(model, [ foldDialogClose, stepModel => ({ model: evo(stepModel, { isSubmitting: () => false }), }), ]) ``` `combine` appends the Commands to its returned array in Step order. The runtime forks those Commands independently, so an application must not depend on their execution or completion order. Do not wrap one Step in `Update.combine`; call that operation directly. ### Combining Independent Results Independent child inits are not a sequence because neither child updates the other child's Model. Initialize them separately and assemble the parent Model: ``` const homeInit = Home.init() const roomInit = Room.init(route) return { model: { home: homeInit.model, room: roomInit.model, }, commands: [ ...Command.mapMessages(homeInit.commands, toGotHomeMessage), ...Command.mapMessages(roomInit.commands, toGotRoomMessage), ], } ``` ## Preventing Lost OutMessages Use `Update.Return` for an update that cannot emit an OutMessage. TypeScript rejects assigning an OutMessage-producing result to it: ``` const childUpdate: Update.ReturnWithOutMessage< Child.Model, Child.Message, Child.OutMessage > = Child.update(model.child, message) // Type error: childUpdate may contain an OutMessage that this type cannot hold. const plainChildUpdate: Update.Return = childUpdate ``` This protects the OutMessage from being lost while a caller keeps only the Model and Commands. An OutMessage-aware return type also accepts a result that emitted nothing: ``` const plainUpdate: Update.Return = { model } const submodelUpdate: Update.ReturnWithOutMessage = plainUpdate ``` An OutMessage-aware caller can accept a plain result because an update is allowed to emit nothing. ### Returning an OutMessage When the OutMessage is already known while constructing a new result, include it directly: ``` return { model, outMessage: OutMessage.Closed() } ``` Use `Update.withOutMessage` when attaching an OutMessage to an existing plain result or when the value has the type `OutMessage | undefined`. If an operation already produced the plain result, pipe that named result into the helper: ``` const dialogClose = closeDialog(model) return pipe(dialogClose, Update.withOutMessage(outMessage)) ``` The object-spread alternative is easy to get wrong: ``` // Avoid: this writes outMessage: undefined and accepts a result that already has an OutMessage. return { ...dialogClose, outMessage } ``` `Update.withOutMessage` preserves `dialogClose.model` and `dialogClose.commands`. A defined value becomes `outMessage`; `undefined` leaves the property out. The update result must be a plain return, so the helper cannot overwrite an OutMessage another operation emitted. When constructing the plain result in the same expression and the value has the type `OutMessage | undefined`, pass the result first: `Update.withOutMessage({ model, commands }, outMessage)`. --- Source: https://foldkit.dev/core/view Section: Core Concepts # View ## Model In, HTML Out The view function turns the Model into HTML. Given the same Model, it produces the same output. It does not modify state or run Effects. Event attributes complete the loop. They produce Messages for the runtime to dispatch, and update decides what those Messages mean. The view remains a pure description of what the user should see and which facts an interaction can report. In the [restaurant analogy](https://foldkit.dev/core/architecture#the-restaurant-analogy), view is the meal on the table. The Model records the current facts; view presents them. ``` import type { Document, HtmlBuilder } from 'foldkit/html' // VIEW const view = (model: Model, h: HtmlBuilder): Document => ({ title: `Counter: ${model.count}`, body: h.div( [h.Class(containerStyle)], [ h.div( [h.Class('text-6xl font-bold text-gray-800')], [model.count.toString()], ), h.div( [h.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. h.button( [h.OnClick(ClickedDecrement()), h.Class(buttonStyle)], ['-'], ), h.button( [h.OnClick(ClickedReset()), h.Class(buttonStyle)], ['Reset'], ), h.button( [h.OnClick(ClickedIncrement()), h.Class(buttonStyle)], ['+'], ), ], ), ], ), }) // STYLE const containerStyle = 'min-h-screen bg-cream 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' ``` No hook rules React functional components can hold local state and run effects through hooks, which introduces ordering rules. A Foldkit view has neither hooks nor local state. It is a function from Model to Html. ## The Document A `makeApplication` view returns a `Document`, not bare HTML. The Document contains the body to patch into the application container and the document-level state that should track the Model. Field Type Required What the runtime does with it `title` `string` Yes Writes it to `document.title` , so the browser tab tracks the current page. `body` `Html` Yes Patches it into the application container. `lang` `string` No Syncs it to `lang` on `` . Omit it and the current value stands. `dir` `'Ltr' | 'Rtl' | 'Auto'` No Syncs it to `dir` on `` , lowercased. Omit it and the current value stands. `canonical` `string` No Syncs it to `` , creating the tag if absent. Defaults to the current URL. `ogUrl` `string` No Syncs it to `` , creating the tag if absent. Defaults to `canonical` . Every field is a function of the Model, just like `body`. There is no imperative `setTitle` or separate head-management API. Return the values you want, and the runtime makes the document match after each render. A `makeElement` view returns `Html` directly. An embedded app does not own the page, so it cannot declare the title or document metadata. Everything outside this section applies to both kinds of view. See [Runtime](https://foldkit.dev/core/runtime#make-element) for when to use each one. ### Language and Direction `lang` and `dir` sync to the `` element. Drive them from the Model when the application can switch languages at runtime, just as `title` tracks the current page. ``` import type { Document, HtmlBuilder, TextDirection } from 'foldkit/html' // TRANSLATION const translate = (locale: Locale, key: string): string => { // Your catalog lookup. Foldkit does not ship translation. return catalog[locale][key] } // VIEW const languageTag: Readonly> = { English: 'en', Arabic: 'ar', Japanese: 'ja', } const textDirection: Readonly> = { English: 'Ltr', Arabic: 'Rtl', Japanese: 'Ltr', } const view = (model: Model, h: HtmlBuilder): Document => ({ title: translate(model.locale, 'PageTitle'), lang: languageTag[model.locale], dir: textDirection[model.locale], body: h.div( [h.Class('mx-auto max-w-prose p-6')], [ h.h1([], [translate(model.locale, 'PageTitle')]), localePicker(model.locale, h), ], ), }) ``` `dir` accepts `'Ltr'`, `'Rtl'`, or `'Auto'`. The runtime writes the corresponding lowercase attribute value. `Auto` delegates to the browser's first-strong-character heuristic. If the Model stores direction rather than deriving it, use the `TextDirection` Schema exported by `foldkit/html`. Neither field has a default. If view omits one, the runtime leaves the existing attribute alone. An application that never sets `lang` therefore keeps the value from `index.html`. The runtime can only synchronize these fields after the first render. Served HTML still determines what a crawler sees on first paint. If language is known per request, stamp `` into the HTML shell and let the runtime keep it current after startup. Use the `Lang` attribute on an individual element when only one passage differs from the page language. ### Canonical and Share URLs `canonical` and `ogUrl` keep `` and `` current as the route changes. If both are omitted, they resolve to the current URL. If only `canonical` is set, `ogUrl` uses the same value. Set them explicitly when the address bar does not identify the page you want indexed or shared. For example: later pages in a paginated list may point to the first page as canonical. On a server render, the default is the full request URL, including its query string. Set `canonical` explicitly when a query parameter is not part of the page's identity, such as a tracking parameter or session token. Otherwise, a crawler can treat each query variant as a separate canonical page. ## Typed HTML Helpers Every view receives `h`, an `HtmlBuilder` typed to the application's Message union. Elements, attributes, and handlers all come from this builder: ``` import type { HtmlBuilder } from 'foldkit/html' // Every view receives `h`, the typed Html builder, as its last argument. // Reach for `h.` to access elements, attributes, and event handlers. // Every callback is typed against your Message union, so `h.OnClick(...)` // only accepts your variants. const greeting = (name: string, h: HtmlBuilder) => h.div( [h.Class('flex flex-col gap-2')], [ h.h1([h.Class('text-2xl font-bold')], [`Hello, ${name}`]), h.button([h.OnClick(ClickedRefresh())], ['Refresh']), ], ) ``` The Message type follows the builder. If `h.OnClick` receives a Message outside the application union, TypeScript rejects it. The root runtime supplies its builder, and `Submodel.defineView` supplies one for each child view. Element builders take attributes first and optional children second. Omit the children argument when there are no children: `h.div([h.Class('divider')])`. Attributes remain required, so `h.div([])` represents an element with neither attributes nor children. `h.keyed` follows the same rule, with the key before the attributes and children. Void elements such as `h.img` and `h.br` accept attributes only. The `foldkit/no-empty-children-array` [lint rule](https://foldkit.dev/tooling/oxlint-plugin#no-empty-children-array) catches a trailing `[]` that carries no information. Application code cannot construct a builder. It only enters through a view parameter, which keeps its Message type aligned with the boundary that dispatches its handlers. Extracted view helpers should take `h: HtmlBuilder` as their last parameter. A helper that works under any parent can introduce a `ParentMessage` generic and accept `h: HtmlBuilder`. At module scope, where no view builder exists, `foldkit/html` exports `inertHtml`. It is an `HtmlBuilder`, so it can build elements and styling attributes but cannot express a Foldkit event handler. Its `Attribute` values can be used with any Message type, which lets a library publish reusable, handler-free attribute bundles. Import it as `ih` to distinguish it from the live builder passed to view. Markup built with `ih` may still vary with runtime data; inert means that it cannot dispatch a Message. Inside a view, use the builder supplied to that view. Inert HTML is inert to Foldkit dispatch, not to the browser. A raw DOM attribute can still trigger browser behavior. The [crash view](https://foldkit.dev/core/crash-view), for example, uses `h.Attribute('onclick', 'location.reload()')` because its `HtmlBuilder` cannot dispatch into a stopped update loop. Raw HTML, script sources, and script attributes need trusted content Several inputs run whatever you pass them, in the browser and in server-rendered HTML alike, and Foldkit does not sanitize them: - `h.InnerHTML` and `h.Srcdoc` render their strings as raw HTML. - A property named `innerHTML` from `CustomElement.define` writes raw HTML when the client assigns it, even though server rendering does not serialize that property. - `h.Attribute` writes a raw DOM attribute, including URL and event-handler attributes, without the sanitization a typed builder may apply. - A raw `onclick`-style attribute runs its string as script. - Text or `h.InnerHTML` inside a `script` or `style` element is emitted as trusted raw-text content. Script content executes, and style content can load resources and change the page. - The `src` of a ` ``` The script type makes the payload data rather than executable JavaScript. Foldkit escapes values that could close the script element. Hydration then parses and Schema-decodes the text. Flags are public HTML, not a place for secrets. ### Opting in from the client entry The client opts into the handoff in its entry (`src/entry.ts` in the examples): ``` Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID }) ``` `Runtime.run` always builds the DOM from scratch. An application with Flags supplies its client-only Flags Effect at that boundary: ``` Runtime.run(application, { flags }) ``` `Runtime.hydrate` accepts no client Flags producer. It reads the serialized Flags, calls the same `init`, and adopts matching server DOM nodes. Element identity, focus, scroll position, and media state survive while listeners and Mounts attach. A mismatched subtree is rebuilt from its nearest parent. Rebuilding discards the DOM identity and browser state that adoption preserves. Development logs a warning that points to nondeterministic Flags, `init`, or view output. Production rebuilds silently, so test hydration before shipping. Calling `hydrate` declares that a complete server handoff exists. If the handoff is invalid, startup stops before Foldkit adopts DOM and the page is put out of reach. [What a refusal does](#what-a-refusal-does) describes that state. This is safer than booting a different client Model over the server's HTML. Use `run` from a separate client entry when the page must also support a fresh SPA boot. `isHydratable` defaults to `true` for SSR and SSG. Set `isHydratable: false` only for static markup that no client will hydrate. The output then carries no application stamp, build id, Flags payload, key marker, or identity marker. `Runtime.hydrate` refuses it. ### Flags and what only the browser knows Hydration requires the server and browser to build the same first Model. Embedded Flags let the browser call `init` with the values the server used. Request-time SSR can derive Flags from the request, including the URL, headers, and cookies. Build-time SSG writes one file for every visitor, so its Flags must be universal and fixed at build time. Flags are public Every serialized Flag ships in the page's HTML. Never place credentials, private tokens, or other secrets in Flags. Browser-only facts do not belong in hydratable SSG Flags. For example: a theme stored in `localStorage`, the viewport width, and browser feature detection are unknown during the build. Start with a neutral Model on both sides. Load browser facts through a boot-time Command or Subscription after hydration. When a preference must affect the server HTML, make it request-visible, such as through a cookie, and use request-time SSR for that URL. ### The build id The build id does not make hydration correct. It makes hydration refuse when it would otherwise be incorrect. The server stamps the id on the rendered root. The client bundle carries the same value. Hydration compares them before it accesses the Flags payload text or adopts DOM. Different ids stop startup; matching ids allow hydration to continue. Most structural mismatches are safe because Foldkit rebuilds the affected subtree. The dangerous case is markup that has the same shape but a different meaning. For example: an old page may place `` where the new build places ``. Without a build check, hydration could preserve text entered before startup and submit it under the new field name. Flags create the same risk. A payload belongs to the deployment that rendered it. A new Schema may accept the old data even when its values now mean something different. The deployment supplies the id because Foldkit cannot infer it. Imported constants, configuration, and caller arguments can change a view's output without changing the view function. `@foldkit/vite-plugin` compiles the value from its `buildId` option or `FOLDKIT_BUILD_ID` into application code as `import.meta.env.FOLDKIT_BUILD_ID`. The client and server entries pass that value explicitly: ``` // vite.config.ts: the plugin compiles the value into application code, from // its `buildId` option or from FOLDKIT_BUILD_ID. foldkit({ buildId: process.env.DEPLOYMENT_SHA }) // src/entry.server.ts Server.renderToString(config, { flags, buildId: import.meta.env.FOLDKIT_BUILD_ID, }) // src/entry.ts Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID }) ``` Whatever value you pick, three things have to be true: - It is public. The id appears in the HTML sent to every visitor, so it must not contain a secret. - It identifies one deployment. Reusing an id makes a stale page look current and produces no warning. A commit or version is insufficient when the same revision can be deployed with different rendering inputs. The `ssr` and `ssg` scaffolds generate a fresh id whenever `FOLDKIT_BUILD_ID` is unset. - It reaches both builds. The client and server run as separate commands, so one build script must pass the same value to both. The scaffolds provide this coordination in `scripts/build.mjs`. A unique CI deployment id is a good source. A commit SHA or release tag is enough only when every deployment carrying it has identical rendering inputs. A hydratable render without an id fails with `MissingBuildId`. `Runtime.hydrate` also requires one. A static render with `isHydratable: false` needs none. Only a build takes the id from the deployment. The development server compiles the fixed value `development` into its server and client transforms. Development runs one live source session rather than producing independently deployable artifacts, so there is no deployment identity to derive. ### Why view identity cannot replace the build id A view identity names a module path and function. It does not capture imported constants, configuration, or caller arguments. View identity also ships in the client bundle. Adding a source hash would expose a digest of that source to every visitor. A reader could test candidates for a low-entropy server-only value by hashing each one, even when the client build removed the value itself. A deployment-supplied build id detects skew without hashing source files. ## Request-time SSR In development, enable the Vite host in `vite.config.ts`: ``` foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } }) ``` Vite continues to serve the client entry, HMR, and assets. Requests that reach Foldkit become Web `Request` values and pass to `renderPage`. The returned Web `Response` provides the status, headers, and body. A hot update does not exercise hydration. HMR preserves the Model but rebuilds the DOM under the root. That DOM came from code that predates the edit. Reload the page to test hydration itself. The stamped root remains required during a hot update; without it, startup fails as it would on a fresh load. In production, build the client and server host separately. The host serves static assets first, imports the built entry, and sends `Server.toResponse(template, await renderPage(request))`. The [SSR example](https://github.com/foldkit/foldkit/tree/main/examples/ssr) uses an Effect `HttpServer` for this delivery layer. Caching personalized responses When Flags depend on the request, such as a cookie, authorization header, or locale, the rendered HTML belongs to that visitor. Set `cache-control` and `vary` so a shared cache cannot serve it to someone else. The SSR example uses `private, no-store` and `vary: cookie` because its initial count comes from a cookie. ## Build-time SSG An SSG host builds the browser bundle and server entry. A build script then calls `renderPage` once for every generated URL (`scripts/prerender.ts` in the SSG example): ``` for (const path of prerenderPaths) { const request = new Request(`https://example.com${path}`) const result = await serverEntry.renderPage(request) if (result._tag === 'Responded') { throw new Error(`Cannot write a Response for ${path} as static HTML`) } const html = Server.injectIntoTemplate(template, result.application) await writeRoute(path, html) } ``` Keep a copy of the template outside the build output, and take the built file as the template only while it still holds the placeholder. The generated `/` replaces `dist/client/index.html`, which is the file the client build left the template in, so a second run against one client build finds no `
    ` there and stops with `injectIntoTemplate found no exact
    placeholder in the template`. The application's own `index.html` still has its placeholder and is never the file at fault. Reading the template before the loop is not enough on its own, because the loop that destroys it and the run that needs it are different runs. A static file is a body plus whatever headers the file host adds. It cannot carry a redirect, a 404, or per-response headers. Writing a `Responded` result to disk turns a redirect into an ordinary page at that URL. The build should fail on `Responded` and on any rendered status it cannot reproduce. The [SSG example](https://github.com/foldkit/foldkit/tree/main/examples/ssg) is the minimal reference. This website is the production-scale reference. Its prerender host uses the same `renderPage(Request)` contract, seeds route content through universal Flags, and writes every route as hydratable static HTML. ## Deploying A deployed SSG build is a directory of static files. Any static host or CDN can serve it as is. The hydration handoff already lives in the HTML. A deployed SSR application needs a host with two jobs: serve the built client assets and call `renderPage` for page requests. On Node, use the [SSR example's server](https://github.com/foldkit/foldkit/tree/main/examples/ssr/server) as the reference. It serves static files first and sends `Server.toResponse(template, await renderPage(request))` for everything else. ### Which methods reach the entry These rules apply to request-time SSR. An SSG deployment is a directory of files, so its static host owns method handling. An SSR host serves static files for `GET` and `HEAD`. Other application methods reach the entry, including `OPTIONS`. Under `vite dev`, a configured proxy route may answer first. Development and the production SSR host follow the same rule. A form action, `Server.Responded` reply, or CORS preflight should not work during development and fail after deployment. `OPTIONS` reaches the entry because a preflight concerns one application resource. Only the application knows its policy. The SSR example and scaffold answer with 204 and an `Allow` header as a placeholder. Replace that response with a real CORS policy before deploying. Preflight ownership follows `Access-Control-Request-Method`, not the URL suffix alone. For example: an `OPTIONS` request for `POST /submit.json` reaches the entry even though a `GET` for that path could name a static asset. An `OPTIONS` request without both `Origin` and `Access-Control-Request-Method` is not a CORS preflight. It reaches the entry regardless of its path. Vite still owns configured proxy routes, source modules, assets, and HMR. Its `server.cors` policy applies to those responses. Requests that fall through to Foldkit do not inherit that development-only policy. The entry's response headers therefore predict the deployed host. The plugin validates the request target before Vite or Foldkit handles it. Proxies still have the opportunity to answer before the server entry. Application `OPTIONS` requests that fall through still reach the entry. `CONNECT`, `TRACE`, and `TRACK` never reach the entry. The WHATWG `Request` constructor rejects them, so the host answers 405 with `Allow`. On Node, only `TRACE` reaches that rule. The HTTP parser answers `TRACK` with 400 before a handler runs. `CONNECT` arrives on its own event rather than as an ordinary request. ### Caching A static build is one file for every visitor, so it caches like any other static asset. Request-time rendering depends on its Flags. A route with universal Flags can use shared caching. A route whose Flags come from the request produces HTML for one visitor. A CDN or reverse proxy must not serve that response to the next visitor. Set the response headers in the server entry and confirm that every cache in front of it honors them. ### Fetch-native runtimes Cloudflare Workers, Deno, and Bun already use Web `Request` and `Response`, so they can run the entry without an adapter: ``` import { Server } from 'foldkit/experimental' import template from './dist/client/index.html' import { renderPage } from './dist/server/entry.server' export default { fetch: async (request: Request): Promise => Server.toResponse(template, await renderPage(request)), } ``` The platform serves the built client assets, and the handler covers page requests. Configure the bundler to treat the template's `.html` import as a string. Cloudflare's Wrangler CLI calls this a `Text` module rule. The same built server entry runs unchanged on each runtime. [Alchemy](https://alchemy.run) can provision and deploy the host. It is TypeScript-native infrastructure as code built on Effect. The Worker and its databases, object storage, or queues live in the same TypeScript program as the entry. Its [Cloudflare support](https://alchemy.run/cloudflare/) deploys the Worker directly. ## Using SSG and SSR together SSG and SSR are delivery policies, not separate Foldkit application types. A hybrid deployment can generate stable routes during the build and send the remaining URLs to a request-time host. Both hosts import the same server entry, and every page hydrates through the same client entry. For example: documentation and marketing pages can be generated at build time, while account pages and preview URLs render per request. Give each route one authoritative policy. Otherwise, one request may receive a generated page from the CDN and the next may receive a fresh page from the runtime host. ## What a refusal does Two things happen. Startup stops, and the page is put out of reach. ### Startup stops Every refusal stops before `init` runs. No Command, Subscription, or ManagedResource from this boot starts. For a build-id mismatch, Foldkit compares ids before accessing the Flags payload text, parsing its JSON, or Schema-decoding it. Stale Flags belong to the old deployment. Decoding them first would pass those values to current code before Foldkit noticed the mismatch. Flags-related refusals inspect the payload only far enough to identify the reported error. Every refusal reports a `[foldkit]` error that names the cause. Failures found while `makeApplication` resolves the container and stamped root throw immediately. Failures found after `Runtime.hydrate` starts use Effect's error reporting. Both reach the console and error monitoring. Neither provides an application hook because startup never reaches a Model. Build skew is one reason to refuse. The same policy also covers: - A Flags payload that is missing, duplicated, malformed, or rejected by the Schema. - A runtime id claimed by two roots, or more than one stamped root with distinct ids. - An empty root stamp, or a requested stamped root outside the document body light DOM. - A served root that lost its stamp. A generated client reaches this state when template insertion already replaced its `#root` placeholder, leaving neither the stamp nor the placeholder. One missing-container case is different. If `makeApplication` cannot find its container and the document contains no `data-foldkit-app`, `data-foldkit-build`, or `data-foldkit-flags`, then no server rendered the page. The application's `
    ` is simply absent, usually because of a typo or because the script ran too early. Foldkit reports the setup error and leaves the page alone. Every other refusal contains the page. This includes calling `Runtime.hydrate` with an existing container that has no stamped root, even on a page that was never server-rendered. Calling `hydrate` is the explicit claim that a handoff exists. Use `Runtime.run` for a fresh client boot. ### Page containment Foldkit marks the document body with `inert`, `aria-hidden`, and `data-foldkit-refused`. It opens a nondismissable modal shield beside the body and above existing top-layer content, including dialogs in closed shadow roots. The shield takes focus. Document-level input guards keep physical keyboard input from reaching stale handlers in the same document if older top-layer content requests focus. Author-owned dialogs remain open behind the shield. Containment does not call `close()` or dispatch `cancel`, either of which could run a stale listener while startup is failing. Pointer and physical keyboard input do not activate links, forms, or controls in that document. The shield asks the visitor to reload. The served DOM remains connected, and `data-foldkit-refused` is available for styling or monitoring. Nothing else in Foldkit sets that attribute. Nothing moves. Foldkit marks the existing body instead of wrapping the application root. Wrapping would reparent the subtree, call `disconnectedCallback` and then `connectedCallback` on every upgraded custom element, and reload every iframe. Marking the body avoids those lifecycle effects. The body is the containment boundary because every hydratable root sits inside it. `renderToString` refuses `html`, `head`, and `body` roots, and `hydrate` is reserved for an application that owns the page. ### Limits of containment Containment starts only after the client detects a refusal. It cannot undo earlier activity: - The parser may already have fetched subresources or run scripts from the old deployment. - A custom element may already have run `connectedCallback`. - A visitor may have interacted with the page before the client entry ran. A script can still submit a form programmatically despite `inert`. - Containment is not a script or global-event sandbox. Capture listeners on `window` or `document` run before an event reaches the shield. The browser may also dispatch global or top-layer events. - An iframe has its own document. Stale code can focus a control inside it, and physical keyboard input dispatched there does not reach the parent document's guards. - A timer or stale listener can open a new dialog after containment. That dialog enters the top layer above the shield. The shield covers top-layer content that existed when refusal began without invoking its lifecycle. ### Stale HTML and caches The build id acts only when the HTML and client bundle come from different deployments. A page cached whole usually references its original content-hashed bundle. Old HTML then loads old JavaScript, the ids match, and Foldkit does not refuse it. If the old assets have been deleted, the client script returns 404 and nothing boots. That is not a refusal and produces no `[foldkit]` error because Foldkit never runs. A mismatch requires stale HTML whose script resolves to current code. Shared caches, partially invalidated CDN nodes, and service workers that retain an application shell can create that pair. A running tab is not rechecked when a deployment lands. Keep stale HTML out of shared caches. Serve the page and its client bundle from the same deployment. ### Recovering from a refusal A refresh usually fixes a refusal by fetching HTML from the current deployment. A refresh cannot help while a CDN node or cache-first service worker keeps returning the old page. Recovery then depends on that cache updating. Foldkit cannot control the service worker lifecycle. Foldkit does not reload automatically and exposes no refusal hook. The runtime does not exist yet, so [`crash.report`](https://foldkit.dev/core/crash-view#crash-report) never runs. Container-resolution failures throw immediately; later hydration failures use Effect's error reporting. Automatic reload would also be unsafe. If stale HTML remains in the cache, each reload receives the same dead page and starts another loop. ## Limitations ### Rendering constraints Server rendering has no browser and runs only the first view over the initial Model. - Commands do not run during a server render. Data loaded by a Command therefore appears as the Model's pre-Command state, usually a loading state. Supply the data through Flags when it must appear in the server HTML. - Components that measure the DOM before deciding what to render, such as `Ui.VirtualList`, render their initial unmeasured state and fill in after hydration. - `makeElement` and `embed` applications do not hydrate. Server rendering supports page-owning `makeApplication` programs. - Ordinary element children under `template` cannot be server-rendered because browsers place them in a separate content fragment that the differ does not walk. Element children under `noscript` become raw text while scripting is enabled and cannot hydrate as the declared nodes. Keep template markup in the HTML shell. Use plain text, trusted `h.InnerHTML`, or shell markup for a noscript fallback. - Dynamic HTML tag names are normalized to lowercase, matching the elements `document.createElement` produces. SVG and MathML tag names are case-sensitive and must use their canonical spelling. `renderToString` refuses a foreign-content spelling that the HTML parser would adjust because `createElementNS` would preserve the original name on a fresh client render. ### DOM and form ownership Server HTML and client DOM must give each attribute, property, and content slot one owner. - `h.Style` owns individual CSS declarations rather than the whole `style` attribute. It accepts known camel-case or declaration names, plus custom properties beginning `--`, with one string value per declaration. It rejects `cssText`, Snabbdom lifecycle keys, duplicate names for one declaration, non-string values, `!important`, and syntax that can escape into another declaration. Server and client renders agree on effective CSS, though not necessarily on the exact attribute bytes or mutation history. Hydration avoids rewriting unchanged declarations. When a strict CSP blocks the parsed style attribute, the client reapplies declared properties through CSSOM. - Text entered into a controlled input before hydration yields to the Model when Foldkit reasserts controlled values. Controlled `value`, `checked`, `selected`, and `muted` state owns the corresponding live and default DOM state. Hydration, a fresh render, and `form.reset()` therefore agree. Removing the typed property clears that ownership or restores a remaining raw attribute. Ownership changes are observable DOM writes, so a MutationObserver may report them. Element identity, focus, and page scroll survive. - A controlled `h.Value` cannot share a `textarea` or `output` with declared children because both own the element's content. Keep either the controlled value or the children. This rule also applies to client-only rendering. - A raw `h.Attribute` and a typed builder cannot name the same attribute on one element. `h.Style` likewise cannot share an element with a raw `style` attribute. Keep one owner for each piece of state. - A typed reflected builder is client-only when the HTML element's native interface does not own that property. For example: spreading `h.Type('button')` onto a `div` creates an expando, so server rendering omits it instead of creating an attribute that a fresh client render would not. Use the matching element when the value must appear in markup, or use an intentional raw `h.Attribute`. - A `CustomElement.define` property named `value` cannot control a native `select` in a server-rendered view. A fresh client assigns the property before the options exist, while hydration assigns it after the parser has created them. The two writes can select different options. Property factories belong on the Custom Element they declare. Use `h.Value` so a native select has one controlled selection. ### Custom Elements Custom Elements may upgrade before hydration. These rules divide state between the component and the view. - Attributes added by a Custom Element's `connectedCallback` survive when the view does not declare them. Component-added class tokens and style properties also survive when the view uses `h.Class` and `h.Style`. A raw `h.Attribute('class', ...)` or `h.Attribute('style', ...)` owns the whole attribute and replaces component additions. - Component-built light DOM survives when the view declares no content. Foldkit adopts that childless host. When the view declares text, children, or `h.InnerHTML`, Foldkit replaces the host and builds the declared content while the new element is detached. The old host disconnects, and the new host has a new DOM identity. It connects once with the view content in place, as it does during a fresh render. This boundary is necessary because a browser may connect the old component before parsing its server content. Hydration cannot distinguish that content from nodes the component inserted and retained, and clearing the old host could let a child's `disconnectedCallback` mutate it during reconciliation. - Keep lifecycle DOM writes within the Custom Element's own host or shadow root. Hydration resamples view-owned element and text state after lifecycle callbacks. It does not sandbox callback code or rescan structure that a component changes elsewhere. - Declared custom-element properties are client behavior, not markup. They apply after hydration and never serialize as attributes. A component property named `id` or `title` stays client-side, while `h.Id` and `h.Title` still serialize the reflected attributes shared by all elements. Native elements continue to reflect their standard properties. For example: a server-rendered `