Build Your Own Bedrock · Part 14

A runtime in 55 lines

TypeFirst · AgileLabs Engineering · 8 min read

Thirteen parts in, everything you've built is data and pure functions. But an app has time in it: clicks happen, fetches resolve, state changes. Something has to own the mutation. In bedrock that something is one file — the centerpiece of the whole codebase — and it fits in a blog post. This is The Elm Architecture with one twist: instead of a message enum plus a giant update function, an action is the update function.

The whole runtime

// Web/src/Runtime/Internal.ts
/***
 * Runtime of web:
 * This is based on The Elm Architecture except that action is a
 * function to update state
 *
 * Life cycle:
 * First run: [initState, initCmd] -> render
 * On each Emit: emit(Action) -> Action(current state)
 *   -> [newState, newCmd] -> render
 **/

export type Action<S> = (state: S) => [S, Cmd<S>]
export type Cmd<S> = Array<Promise<Action<S> | null>>
export type EmitFn<S> = (a: Action<S>) => void
type RenderFn<S> = (state: S) => void

/**
 * Entry point to start the runtime
 * */
export function start<S>(
  initState: S,
  initCmd: Cmd<S>,
  renderState: RenderFn<S>,
): EmitFn<S> {
  let singletonState: S = initState

  // Creates the emit function that is tied to state S
  // It uses the closure of mutableState in order to maintain
  // a single state
  // Javascript is a single-thread runtime which guarantees us
  // that emit will always run sequentially
  const emit: EmitFn<S> = (action: Action<S>) => {
    const [newState, cmd] = action(singletonState)
    singletonState = newState
    runCmd(emit, cmd)
    renderState(singletonState)
  }

  // First run of the program
  runCmd(emit, initCmd)
  renderState(singletonState)

  // Subsequently, any changes to State is only caused by emit
  // Return the emit function for app to change state
  return emit
}

// Run all Cmd as runaway promises
// so as not to block any rendering
function runCmd<S>(emit: EmitFn<S>, cmd: Cmd<S>): void {
  cmd.forEach((promiseAction) => {
    promiseAction
      .then((action) => {
        return action == null ? null : emit(action)
      })
      .catch((e) => console.error(e))
  })
}

That's it. That is the store, the dispatcher, the effect system, and the render scheduler. Four decisions worth naming:

An action is a function(state) => [newState, cmd]. No action-type enums, no reducer switch, no dispatch-then-lookup indirection. Effects are data — a Cmd is just an array of promises that each resolve to the next action (or null for fire-and-forget). Actions stay pure; the runtime is the only thing that executes effects. One mutable cellsingletonState lives in a closure. Nothing else in the app mutates; JavaScript's single thread guarantees emit runs sequentially, so there are no races and no locks. The loop is total — every state change goes action → new state → run cmds → render. There is no second path, which is why "how did state get like this?" has exactly one answer: some action, loggable at one choke point.

The app specializes it

// Web/src/Action.ts
import type { State } from "./State"

export type Action = (s: State) => [State, Cmd]
export type Cmd = Array<Promise<Action | null>>

/** Sugar syntax */
export function cmd(...xs: Cmd): Cmd {
  return xs
}

/** Sugar syntax */
export function perform(a: Action): Promise<Action> {
  return Promise.resolve(a)
}

T5 of the ladder: app actions are the runtime's Action<S> pinned to the app's State. React's only job is drawing:

// Web/src/Runtime/React.tsx
function render(state: State): void {
  // React does batch rendering instead of rendering synchronously
  // Hence we force React to render synchronously here
  // on every state update
  flushSync(() => {
    root.render(
      <React.StrictMode>
        <View state={state} />
      </React.StrictMode>,
    )
  })
}

runtimeEmit = Runtime.start(initState, initCmd, render)

The whole tree re-renders from one state value, synchronously, on every emit — no useState, no useEffect, no context, no memo graph to reason about. State lives outside React; components are functions of state that call emit. (Yes, this scales; see the post-script links.)

Guarding state families with lenses

// Web/src/State.ts
export type State = PublicState | AuthState

export type AuthState = Omit<PublicState, "_t"> & {
  _t: "Auth"
  profile: User
  updateProfile: UpdateProfileState
}

// Lenses
export function _AuthState(
  fn: (authState: AuthState) => [State, Cmd],
): Action {
  return (state: State) =>
    state._t === "Auth" ? fn(state) : [state, []]
}

State itself is a sum: logged-out and logged-in are different types, and profile only exists on AuthState. The _AuthState lens builds actions that only fire in the Auth family — if a stale promise resolves after logout, the action is a no-op instead of a crash on missing profile. Invalid states unrepresentable, applied to time.

The login flow, end to end

Here is the machinery from Part 13 actually turning:

// Web/src/Action/Login.ts
export function onSubmit(params: LoginApi.BodyParams): Action {
  return (state) => {
    return [
      _LoginState(state, { loginResponse: RD.loading() }),
      cmd(LoginApi.call(params).then(onSubmitResponse)),
    ]
  }
}

function onSubmitResponse(response: LoginApi.Response): Action {
  return (state) => {
    if (response._t === "Err") {
      return [
        _LoginState(state, {
          loginResponse: RD.failure(response.error),
        }),
        cmd(),
      ]
    }

    const { user, accessToken, refreshToken } = response.value
    AuthToken.set({ userID: user.id, accessToken, refreshToken })

    return [
      _LoginState(initAuthState(user, state), {
        loginResponse: RD.success(response.value),
      }),
      cmd(perform(navigateTo(toRoute("Home", {})))),
    ]
  }
}

Trace it: the form emits onSubmit → state flips to Loading (button disables, spinner shows) and the fetch promise goes into the Cmd array → the runtime runs it → the promise resolves to onSubmitResponse(response) → that action pattern-matches the Result: failure lands in RD.failure for the view to render; success writes tokens, upgrades the state family to AuthState, and queues one more command — navigation, itself just an action. Every arrow in that sentence is the same emit loop. Nothing is hidden in a hook, a thunk, or a saga.

In your language

This is The Elm Architecture, which Elm gives you natively — update : Msg -> Model -> (Model, Cmd Msg) is the same pair. Swift's Composable Architecture is the industrial version: reducers return effects that feed actions back in. On Android, MVI with a single StateFlow and a reduce loop is this pattern wearing Kotlin clothes. PureScript and ReasonML/ReScript have direct TEA libraries (Halogen's eval loop, rescript-tea). Even in plain Rust or Go, the shape ports: one owned state value, a channel of state-transition functions, effects as values a runner executes. The load-bearing idea is the single loop — 55 lines is the proof it doesn't need a framework.

Does this survive contact with production? TEA at 62,000 lines is the field report, and We banned React hooks is the full argument for keeping state out of the view layer.

← Part 13: The client calls the same contract · All posts · Part 15: Forms that parse, don't validate →