Skip to main content
On this pageOverview

Subscriptions

Overview

A Subscription is a reactive binding between your Model and a long-running stream — timers, WebSocket connections, keyboard input, resize observers, anything that produces a continuous stream of events. You declare which part of the Model the Subscription depends on, and Foldkit manages the stream lifecycle automatically, starting it when dependencies are met and stopping it when they change.

In the restaurant analogy, a Subscription is a standing order: “keep the coffee coming for table 5.” The waiter doesn’t keep walking back to repeat the order — the kitchen knows to keep pouring until the table says stop.

Commands handle one-off side effects — a slip to the kitchen that comes back with a single result. Subscriptions handle the ongoing kind. Here’s how we add auto-counting to the counter: when isAutoCounting is true, a stream ticks every second, and when it flips to false, the stream stops.

import { Duration, Schema as S, Stream } from 'effect'
import { Subscription } from 'foldkit'
import { m } from 'foldkit/message'

// MESSAGE

const ClickedIncrement = m('ClickedIncrement')
const ToggledAutoCounting = m('ToggledAutoCounting')
const Ticked = m('Ticked')

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

// MODEL

const Model = S.Struct({
  count: S.Number,
  isAutoCounting: S.Boolean,
})

type Model = typeof Model.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.seconds(1)).pipe(Stream.map(Ticked)),
        () => isAutoCounting,
      ),
  },
})

The key concept is SubscriptionDeps. This schema defines what parts of the Model your Subscriptions depend on. Each Subscription has two functions:

  • modelToDependencies extracts the relevant dependencies from the Model.
  • dependenciesToStream creates a stream based on those dependencies.

Foldkit structurally compares the dependencies between model updates. The stream is only restarted when the dependencies actually change, not on every model update.

When isAutoCounting changes from false to true, the stream starts ticking. When it changes back to false, the stream stops. Foldkit handles all the lifecycle management for you.

For a more complex example using WebSocket connections, see the websocket-chat example. For a full real-world application, check out Typing Terminal (source).

Subscriptions produce a Stream<Message>. Your dependenciesToStream function returns what happened, and the runtime feeds each Message into update. If a stream callback needs to perform a side effect before producing a Message — like calling event.preventDefault() — use Stream.mapEffect.

Advanced

Consider an auto-scroll Subscription for a drag-and-drop interface. It depends on both isDragging (should the scroll loop run?) and clientY (where is the pointer?). The stream should start when dragging begins and stop when it ends — but clientY changes on every pixel of mouse movement, and by default every change restarts the stream, destroying the requestAnimationFrame loop each time.

import { Effect, Equivalence, Schema as S, Stream } from 'effect'
import { Subscription } from 'foldkit'
import { m } from 'foldkit/message'

const CompletedAutoScroll = m('CompletedAutoScroll')

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

const Model = S.Struct({
  isDragging: S.Boolean,
  clientY: S.Number,
})

type Model = typeof Model.Type

const SubscriptionDeps = S.Struct({
  autoScroll: S.Struct({
    isDragging: S.Boolean,
    clientY: S.Number,
  }),
})

const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
  Model,
  Message
>({
  autoScroll: {
    modelToDependencies: model => ({
      isDragging: model.isDragging,
      clientY: model.clientY,
    }),
    // Only restart the stream when isDragging changes.
    // Without this, every clientY change (every pixel) would tear down
    // and recreate the requestAnimationFrame loop.
    equivalence: Equivalence.struct({ isDragging: Equivalence.boolean }),
    // readDependencies returns the latest dependencies without restarting the stream.
    // The rAF loop calls readDependencies() each frame to get the current clientY.
    dependenciesToStream: ({ isDragging }, readDependencies) =>
      Stream.when(
        Stream.async<typeof CompletedAutoScroll.Type>(emit => {
          let animationFrameId = 0
          const step = () => {
            const { clientY } = readDependencies()
            window.scrollBy(0, clientY > window.innerHeight - 40 ? 5 : 0)
            emit.single(CompletedAutoScroll())
            animationFrameId = requestAnimationFrame(step)
          }
          animationFrameId = requestAnimationFrame(step)
          return Effect.sync(() => cancelAnimationFrame(animationFrameId))
        }),
        () => isDragging,
      ),
  },
})

Custom Equivalence

The equivalence field overrides the default structural comparison with an Equivalence from Effect, letting you choose which fields trigger a restart. Equivalence.struct({ isDragging: Equivalence.boolean }) means two snapshots are equal if they have the same isDragging value, regardless of clientY. The stream starts once when dragging begins and stops when it ends — it never restarts in between.

Reading Live Dependencies

If the stream doesn’t restart when clientY changes, how does the rAF loop read the latest pointer position? The second argument to dependenciesToStream is readDependencies — a function that returns the current deps synchronously, reflecting the latest Model state without restarting the stream.

Inside the requestAnimationFrame loop, readDependencies() returns the latest snapshot every frame. This is the bridge between the stream lifecycle (which gates on the deps that trigger restarts) and browser callbacks (which need synchronous access to the latest state).

In most Subscriptions, use deps directly — the stream restarts whenever they change, so they’re always current. readDependencies is for the case where equivalence has excluded fast-changing fields from the restart decision, and you need to read those fields inside a long-lived callback. For a real-world example, see the Drag and Drop component and the Kanban example.

You’ve now seen how state changes flow through update, how one-off side effects work as Commands, and how ongoing streams are managed with Subscriptions. But where do the first Model and Commands come from? That’s init.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson