Skip to main content
On this pageA Simple Counter

Coming from React

If you know React, you already have the instincts for building UIs. Foldkit channels those instincts through a different structure — one where every state change, every side effect, and every event is explicit and visible. The best way to feel the difference is to build the same thing in both.

A Simple Counter

A counter in React:

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  const handleClickIncrement = () => {
    setCount(count => count + 1)
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClickIncrement}>Increment</button>
    </div>
  )
}

The same counter in Foldkit:

import { Match as M, Schema as S } from 'effect'
import { Command } from 'foldkit'
import { Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'

// MODEL - Your entire application state

const Model = S.Number
type Model = typeof Model.Type

// MESSAGE - Events that can happen in your app

const ClickedIncrement = m('ClickedIncrement')

const Message = S.Union(ClickedIncrement)
type Message = typeof Message.Type

// UPDATE - How Messages change the Model

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const withUpdateReturn = M.withReturnType<UpdateReturn>()

const update = (model: Model, message: Message): UpdateReturn =>
  M.value(message).pipe(
    withUpdateReturn,
    M.tagsExhaustive({
      ClickedIncrement: () => [model + 1, []],
    }),
  )

// VIEW - A pure function from Model to HTML

const { div, button, p, OnClick } = html<Message>()

const view = (model: Model): Html =>
  div(
    [],
    [
      p([], [`Count: ${model}`]),
      button([OnClick(ClickedIncrement())], ['Increment']),
    ],
  )

More lines, same result. At this scale, Foldkit’s structure — Model, Message, update, view — looks like overhead. The benefits come with scale. Every piece earns its place as more complex behavior is introduced.

Adding Auto-Count

New requirement: a play/pause button that auto-increments the counter every second.

React adds a ref to hold the interval ID and a useEffect to start and stop the interval:

import { useEffect, useRef, useState } from 'react'

const TICK_INTERVAL_MS = 1000

function Counter() {
  const intervalRef = useRef<number>()

  const [count, setCount] = useState(0)
  const [isAutoCounting, setIsPlaying] = useState(false)

  const handleClickIncrement = () => {
    setCount(count => count + 1)
  }

  const handleClickAutoCount = () => {
    setIsPlaying(isAutoCounting => !isAutoCounting)
  }

  useEffect(() => {
    if (isAutoCounting) {
      intervalRef.current = setInterval(() => {
        setCount(count => count + 1)
      }, TICK_INTERVAL_MS)
    }

    return () => clearInterval(intervalRef.current)
  }, [isAutoCounting])

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClickIncrement}>Increment</button>
      <button onClick={handleClickAutoCount}>
        {isAutoCounting ? 'Stop' : 'Auto-Count'}
      </button>
    </div>
  )
}

The interval state lives outside React’s state system — in a ref — because the effect needs to clear the previous interval before starting a new one. The cleanup function is critical: miss it and you leak intervals.

Foldkit adds a Subscription and a Message:

import { Duration, Match as M, Schema as S, Stream } from 'effect'
import { Command, Subscription } from 'foldkit'
import { Html, html } from 'foldkit/html'
import { m } 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 ClickedIncrement = m('ClickedIncrement')
const ClickedToggleAutoCount = m('ClickedToggleAutoCount')
const Ticked = m('Ticked')

const Message = S.Union(ClickedIncrement, ClickedToggleAutoCount, Ticked)
type Message = typeof Message.Type

// SUBSCRIPTION

const SubscriptionDeps = S.Struct({
  tick: S.Struct({ isAutoCounting: S.Boolean }),
})

const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
  Model,
  Message
>({
  tick: {
    modelToDependencies: model => ({
      isAutoCounting: model.isAutoCounting,
    }),
    dependenciesToStream: ({ isAutoCounting }) =>
      Stream.when(
        Stream.tick(Duration.millis(TICK_INTERVAL_MS)).pipe(Stream.map(Ticked)),
        () => isAutoCounting,
      ),
  },
})

// UPDATE

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const withUpdateReturn = M.withReturnType<UpdateReturn>()

const update = (model: Model, message: Message): UpdateReturn =>
  M.value(message).pipe(
    withUpdateReturn,
    M.tagsExhaustive({
      ClickedIncrement: () => [evo(model, { count: count => count + 1 }), []],
      ClickedToggleAutoCount: () => [
        evo(model, {
          isAutoCounting: isAutoCounting => !isAutoCounting,
        }),
        [],
      ],
      Ticked: () => [evo(model, { count: count => count + 1 }), []],
    }),
  )

// VIEW

const { div, button, p, OnClick } = html<Message>()

const view = (model: Model): Html =>
  div(
    [],
    [
      p([], [`Count: ${model.count}`]),
      button([OnClick(ClickedIncrement())], ['Increment']),
      button(
        [OnClick(ClickedToggleAutoCount())],
        [model.isAutoCounting ? 'Stop' : 'Auto-Count'],
      ),
    ],
  )

The Subscription emits Ticked every second while isAutoCounting is true. Foldkit manages the stream lifecycle — starts it when the dependency changes to true, tears it down when it changes to false. No refs, no manual cleanup.

Adding a Step Size

One more feature: an input that controls how much each tick and manual click increments by.

This is where the React version hits a wall. The setInterval callback captures step at creation time. If you change the step while playing, the interval keeps using the old value — a stale closure. The fix: a ref and a sync effect to keep it current:

import { useEffect, useRef, useState } from 'react'

const TICK_INTERVAL_MS = 1000

function Counter() {
  const intervalRef = useRef<number>()

  const [count, setCount] = useState(0)
  const [isAutoCounting, setIsPlaying] = useState(false)
  const [step, setStep] = useState(1)
  const stepRef = useRef(step)

  const handleClickIncrement = () => {
    setCount(count => count + step)
  }

  const handleClickAutoCount = () => {
    setIsPlaying(isAutoCounting => !isAutoCounting)
  }

  useEffect(() => {
    stepRef.current = step
  }, [step])

  useEffect(() => {
    if (isAutoCounting) {
      intervalRef.current = setInterval(() => {
        setCount(count => count + stepRef.current)
      }, TICK_INTERVAL_MS)
    }

    return () => clearInterval(intervalRef.current)
  }, [isAutoCounting])

  return (
    <div>
      <p>Count: {count}</p>
      <label>
        Step:
        <input
          type="number"
          value={step}
          onChange={e => setStep(Number(e.target.value))}
        />
      </label>
      <button onClick={handleClickIncrement}>Increment</button>
      <button onClick={handleClickAutoCount}>
        {isAutoCounting ? 'Stop' : 'Auto-Count'}
      </button>
    </div>
  )
}

Two refs, two effects, and a subtle bug that only manifests at runtime — the interval silently uses a stale value until you add the ref workaround. Most React developers have been burned by this.

In Foldkit, there is no stale closure:

import { Duration, Match as M, Schema as S, Stream } from 'effect'
import { Command, Subscription } from 'foldkit'
import { Html, html } from 'foldkit/html'
import { m } 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 ClickedIncrement = m('ClickedIncrement')
const ClickedToggleAutoCount = m('ClickedToggleAutoCount')
const ChangedStep = m('ChangedStep', { step: S.Number })
const Ticked = m('Ticked')

const Message = S.Union(
  ClickedIncrement,
  ClickedToggleAutoCount,
  ChangedStep,
  Ticked,
)
type Message = typeof Message.Type

// SUBSCRIPTION

const SubscriptionDeps = S.Struct({
  tick: S.Struct({ isAutoCounting: S.Boolean }),
})

const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
  Model,
  Message
>({
  tick: {
    modelToDependencies: model => ({
      isAutoCounting: model.isAutoCounting,
    }),
    dependenciesToStream: ({ isAutoCounting }) =>
      Stream.when(
        Stream.tick(Duration.millis(TICK_INTERVAL_MS)).pipe(Stream.map(Ticked)),
        () => isAutoCounting,
      ),
  },
})

// UPDATE

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const withUpdateReturn = M.withReturnType<UpdateReturn>()

const update = (model: Model, message: Message): UpdateReturn =>
  M.value(message).pipe(
    withUpdateReturn,
    M.tagsExhaustive({
      ClickedIncrement: () => [
        evo(model, { count: count => count + model.step }),
        [],
      ],
      ClickedToggleAutoCount: () => [
        evo(model, {
          isAutoCounting: isAutoCounting => !isAutoCounting,
        }),
        [],
      ],
      ChangedStep: ({ step }) => [evo(model, { step: () => step }), []],
      Ticked: () => [evo(model, { count: count => count + model.step }), []],
    }),
  )

// VIEW

const { div, button, p, label, input, OnClick, OnInput } = html<Message>()

const view = (model: Model): Html =>
  div(
    [],
    [
      p([], [`Count: ${model.count}`]),
      label(
        [],
        [
          'Step: ',
          input([OnInput(value => ChangedStep({ step: Number(value) }))]),
        ],
      ),
      button([OnClick(ClickedIncrement())], ['Increment']),
      button(
        [OnClick(ClickedToggleAutoCount())],
        [model.isAutoCounting ? 'Stop' : 'Auto-Count'],
      ),
    ],
  )

model.step is always current. The update function receives the latest Model every time a Message arrives. Both ClickedIncrement and Ticked use model.step and it just works — no refs, no sync effects, no runtime surprises.

Read the update function top to bottom. Every behavior in the app is right there. Each case is independent — they don’t interact through shared mutable state or overlapping effect dependencies. Adding a feature meant adding cases, not restructuring existing ones.

The pattern

In React, complexity compounds. Each feature interacts with existing effects, refs, and closures. In Foldkit, complexity scales linearly. Each feature adds Messages, update cases, and possibly Commands or Subscriptions — but they don’t interact with each other through shared mutable state.

This structure also makes testing trivial. Your update function is pure — pass a Model and a Message, assert on the returned Model. No rendering, no mocking useEffect, no wrapping in providers.

This is a toy example. Consider what happens at real scale — a multiplayer game with WebSocket streams, a mix of client and server state, handling keyboard events, animations, and reconnection logic. In React, every feature adds effects that interact with every other effect. In Foldkit, the architecture is the same as the counter: Messages come in, the update function decides what to do, Commands and Subscriptions handle the rest. The complexity of your domain grows, but the complexity of your architecture doesn’t.

Translating React Concepts

Here’s how React patterns map to Foldkit:

React EcosystemFoldkit
useStateModel (single state tree)
useReducerupdate function
useEffect (one-off)Commands (returned from update)
useContext / Redux / ZustandSingle Model (no prop drilling)
useMemo / useCallbackNot needed (no stale closures)
Custom hooksDomain modules with pure functions
JSXPlain functions from Model to HTML
Component propsFunction parameters
Component statePart of the single Model
Event handlersMessages dispatched to update
React Router / TanStack RouterBuilt-in typed routing
React Hook Form / FormikModel + Messages + foldkit/fieldValidation
Event streams (useEffect / RxJS)Subscriptions (automatic lifecycle)
Headless UI / Radix UIFoldkit UI (headless, typed components)
Error boundariesTyped errors in Effects + crash.view

If you know Redux...

The Model-View-Update pattern will feel familiar. Think of the Model as your Redux store, Messages as actions, and update as your reducer — but without action creators, selectors, or middleware.

FAQ

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson