Skip to main content
Foldkit
On this pageIntroduction

Coming from React

Introduction

If you're coming from React, you already understand component-based UI, state management, and the challenges of building complex web applications. Foldkit takes a different approach — one that may feel unfamiliar at first but addresses many frustrations you've likely encountered.

This guide won't try to convince you that React is bad. React is a great tool that has shaped modern frontend development. But if you've ever struggled with stale closures, effect dependency arrays, or state scattered across components, Foldkit offers an alternative worth exploring.

Mental Model Shifts

The biggest shifts when moving from React to Foldkit:

  • Components → Functions — Instead of components with their own state and lifecycle, you have pure functions. The view is just a function from Model to HTML.
  • Local State → Single Model — There's no useState scattered across components. All application state lives in one place, making it trivial to understand what your app knows at any moment.
  • useEffect → Commands — Side effects are explicit return values from update, not callbacks triggered by dependency arrays. You never wonder "why did this effect run?"
  • Hooks Rules → No Rules — No worrying about calling hooks in the wrong order, in conditionals, or in loops. There are no hooks — just functions.
  • Props Drilling → Direct Access — With a single Model, any part of your view can access any state. You don't need Context or state management libraries to avoid prop drilling.

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 the boilerplate of action creators, selectors, and middleware.

Side-by-Side Code Comparison

Let's compare a counter in React and Foldkit. The React version uses hooks:

import { useState } from 'react'

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

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

The Foldkit version separates state, events, how events update state, and view:

import { Match as M, Schema as S } from 'effect'
import { Command } from 'foldkit/command'
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 = [Model, ReadonlyArray<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']),
    ],
  )

Foldkit is more lines of code. It doesn't optimize for fewer characters or clever shortcuts — it optimizes for honesty. Every state is named, every event is typed, every transition is visible. At small scale that feels like overhead. At large scale it's the reason you can still understand your app.

Here's a data fetching example. React requires careful handling of loading states, race conditions, and cleanup:

import { useEffect, useState } from 'react'

type User = { id: string; name: string }

const fetchUser = async (userId: string): Promise<User> => {
  const response = await fetch(`/api/users/${userId}`)
  return response.json()
}

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)

    fetchUser(userId)
      .then(data => {
        if (!cancelled) {
          setUser(data)
          setLoading(false)
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err)
          setLoading(false)
        }
      })

    return () => {
      cancelled = true
    }
  }, [userId])

  if (loading) return <Spinner />
  if (error) return <Error message={error} />
  return <UserCard user={user} />
}

In Foldkit, the same pattern is explicit, auditable, and safe by default:

import { Effect, Match as M, Schema as S } from 'effect'
import { Command } from 'foldkit/command'
import { m } from 'foldkit/message'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'

const UserSchema = S.Struct({ id: S.String, name: S.String })

const UserLoading = ts('UserLoading')
const UserSuccess = ts('UserSuccess', { data: UserSchema })
const UserFailure = ts('UserFailure', { error: S.String })
const UserState = S.Union(UserLoading, UserSuccess, UserFailure)

// MODEL - your entire application state

const Model = S.Struct({
  userId: S.String,
  user: UserState,
})
type Model = typeof Model.Type

// MESSAGE - events that can happen in your app

const ClickedFetchUser = m('ClickedFetchUser', { userId: S.String })
const SucceededUserFetch = m('SucceededUserFetch', {
  data: UserSchema,
})
const FailedUserFetch = m('FailedUserFetch', { error: S.String })

const Message = S.Union(
  ClickedFetchUser,
  SucceededUserFetch,
  FailedUserFetch,
)
type Message = typeof Message.Type

// COMMAND - descriptions of side effects that resolve to Messages

const fetchUser = (
  userId: string,
): Command<typeof SucceededUserFetch | typeof FailedUserFetch> =>
  Effect.gen(function* () {
    const response = yield* Effect.tryPromise(() =>
      fetch(`/api/users/${userId}`).then(response => response.json()),
    )
    // Validate the response against UserSchema at runtime
    const data = yield* S.decodeUnknown(UserSchema)(response)
    return SucceededUserFetch({ data })
  }).pipe(
    // Every Command must return a Message — no errors bubble up
    Effect.catchAll(error =>
      Effect.succeed(FailedUserFetch({ error: String(error) })),
    ),
  )

// UPDATE - how Messages change the Model

const update = (
  model: Model,
  message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
    // Handle every Message — the type system ensures all cases are covered
    M.tagsExhaustive({
      ClickedFetchUser: ({ userId }) => [
        // evo returns an updated copy of Model
        evo(model, { user: () => UserLoading() }),
        [fetchUser(userId)],
      ],
      SucceededUserFetch: ({ data }) => [
        evo(model, { user: () => UserSuccess({ data }) }),
        [],
      ],
      FailedUserFetch: ({ error }) => [
        evo(model, { user: () => UserFailure({ error }) }),
        [],
      ],
    }),
  )

Notice there's no cleanup function, no cancelled flag, no stale closure risk. The Command runs, and when it completes, it returns a Message. The architecture eliminates the need for defensive coding.

Now read the update function. Every state transition in the app is right there — when the user clicks fetch, set loading and fire the command. When the fetch succeeds, store the data. When it fails, store the error. Want to know what your app does? Read update. In React, the same understanding requires tracing through hooks, effects, and closures across multiple components.

Pattern Mapping

Here's how common React patterns map to Foldkit:

React EcosystemFoldkit
useStateModel (single state tree)
useReducerupdate function
useEffectCommands (returned from update)
useContext / Redux / ZustandSingle Model (no prop drilling)
useMemo / useCallbackNot needed (no stale closures)
Custom hooksDomain modules with pure functions
JSXTyped HTML helper functions
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 + Effect Schema validation
TanStack Query / SWRCommands + Subscriptions + typed async state
WebSocket libraries / real-timeSubscriptions
Error boundariesTyped errors in Effects + errorView

What You'll Miss

Let's be honest about the tradeoffs. Coming from React, you may miss:

  • Component encapsulation — In React, components encapsulate state and behavior. In Foldkit, state is centralized. This is a feature, not a bug, but it requires a different way of thinking about code organization.
  • The React ecosystem — React has thousands of component libraries, UI kits, and integrations. Foldkit is much smaller. You'll often write more from scratch.
  • JSX — Many developers prefer JSX's HTML-like syntax. Foldkit uses function calls that some find more verbose. Others prefer the consistency — it's just functions all the way down.
  • Gradual adoption — You can add React to any page incrementally. Foldkit works best as a full-page application. It's harder to embed a Foldkit widget in an existing React app.
  • Familiarity — Most frontend developers know React. Foldkit's patterns, while not difficult, require learning. Team onboarding takes longer.

What You'll Gain

In return, you'll gain:

  • No stale closures — Ever. The update function always receives the current model. There's no dependency array to get wrong.
  • Explicit effects — Every side effect is a return value from update. You can see exactly what effects a message triggers by reading the code.
  • Testable by default — Your update function is pure. Give it a model and message, check the output. No mocking useState or useEffect.
  • Type-safe everything — Model, Messages, Commands — all typed. Effect Schema validates at runtime too. Fewer "undefined is not a function" errors.
  • No hook rules — Call any function anywhere. No "rules of hooks" to remember, no linter errors about missing dependencies.
  • Single source of truth — Want to know your app's state? It's all in the Model. Want to know what can happen? Look at Messages. Want to know how state changes? Read update.

FAQ