Build Your Own Bedrock · Part 14
A runtime in 55 lines
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 cell — singletonState 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.