This repository is an AI-generated and AI-assisted research prototype. It is intended for experimentation, not as a production-ready framework or starter.
This repo explores a React-like component runtime in Koka, with Algebraic Effects used for browser capabilities, test substitution, component-local state, and event dispatch.
Ordinary components need one framework import:
import explore/reactThis entry exposes elements, typed actions/stores, keyed lifecycle, and effects; advanced runtime, inspection, and renderer modules remain explicit host/test imports.
Run the first component before opening the browser demo:
yarn example:first-componentIt compiles a public-authoring component, prints its initial render, sends one typed action, and prints the updated render. The quick start links to that compiled source instead of maintaining a second tutorial copy.
Learn the normal path as four tasks; runtime ownership and snapshot transport are advanced integration topics, not prerequisites:
- Elements: write ordinary view functions with positional content and labelled attributes/events.
- Keyed components: add
component(...)/components(...)only when a child needs stable lifecycle identity. - Typed stores/actions: define a serializable action and use
(state, dispatch) = use_store(spec, initial = ...). - Effects: use
state_effect(...)for post-render work andstate_resource(...)for setup/cleanup lifecycles; keep browser/service capabilities as explicit Koka effects.
Start with the bilingual component quick start, then use the one-page component author API as the normal reference. When an error interrupts that path, use the bilingual component error and diagnosis cookbook. Persistent feature identity, recovery, and cross-domain/local transitions are introduced only when needed:
The current design deliberately separates two kinds of state:
- domain state lives in the application
modeland changes through typed feature actions; - component state lives in a framework-owned runtime tree and changes through typed, serializable store actions.
That split keeps business data serializable and inspectable while allowing
component drafts, expanded rows, and other transient UI state to survive a
JavaScript hot replacement or be restored from localStorage.
| React idea | This repo in Koka |
|---|---|
| view function | a function returning an app_view vnode expression |
| keyed component instance | component(...) / components(...) |
| reusable persistent feature | an exported component owns feature_root(...) and may expose labelled key; app singletons keep a fixed identity |
| app runtime | one run_component(...) call at the integration boundary |
| local reducer | (state, dispatch) = use_store(spec, initial=...) |
| local dispatch | dispatch(action) or on_store_* |
| app reducer/action | on_action_click(...) / on_action_input(...) / on_action_enter(...) |
| domain + local transition | action_store_transition(...) reused by on_local_click(...) / on_local_enter(...) |
| post-render effect | state_effect("name", deps) { ... } |
| managed effect resource | state_resource(name=..., deps=..., cleanup=..., action=...) |
| Context-like value | a Koka val effect |
| browser/service capability | a Koka fun effect |
| test double | an alternate effect handler |
The goal is not API compatibility with React. The useful parts of its mental model are retained—plain functions, immutable state, stable keyed identity, and actions—while serialization and capability boundaries are made explicit.
Element content is positional. Common attributes and events are flat labelled
arguments, so call sites do not need an attrs or children wrapper:
val (Incident_local_state(expanded, draft), dispatch) =
use_store(incident_local_store, initial = Incident_local_state(False, ""))
article([
strong(item.title, class = "incident-title"),
button(
if expanded then "Collapse" else "Expand",
class = "button",
click = on_store_click(
"toggle-expanded",
action = Toggle_incident,
dispatch = dispatch)),
input_text(
draft,
class = "input incident-input",
input = on_store_input(
"draft-input",
action = Change_incident_draft,
dispatch = dispatch),
placeholder = "Local reply draft..."),
], key = "incident-" ++ iid.show, class = "incident-card")Public view components follow the same props rule as elements: one primary
domain value stays positional, while callbacks and configuration are labelled.
For example, Search exposes on_input = ..., on_submit = ..., and
on_select = ...; it does not ask callers to manufacture raw DOM payloads or
know which listener registry path the framework assigns.
Component signatures use one application alias, app_view, instead of
listing hook_scope, state-tree access, scheduled effects, and the listener
registry in every view file. The alias hides runtime plumbing without weakening
the type of the component body.
A function call alone does not create component identity. A stateful reusable
child is evaluated inside component(...) or components(...); a persistent
top-level feature establishes feature_root(...). Pure view helpers can remain
ordinary function calls because they do not need their own lifecycle.
Component boundaries use Koka's trailing-lambda syntax—
feature_root("todo", key) { ... } and component("tasks", key) { ... }—so
lifecycle-bearing code is visually distinct from an ordinary view helper.
Component props and element attributes remain flat labelled arguments.
For repeated children, components(...) owns the keyed component boundary:
fun incident_grid(lab : workflow_lab) : app_view vnode
div(
components(
lab.incidents,
group = "incidents",
key = fn(item) incident/id(item).show,
render = incident_card),
class = "incident-grid")This is more than a shorter map: the group + key pair determines the stable
scope used by the child component's state, effects, and named listeners.
An exported feature component owns its stable absolute root and still composes
as a vnode expression:
pub fun todo_panel(
panel_state : todo_panel_state,
key : string = "panel"
) : app_view vnode
feature_root("todo", key) {
panel(
[...],
key = feature_key())
}Layouts therefore compose components as ordinary values, without receiving or merging runtime tuples:
section([
route_bar(item),
todo_panel(todo_panel_state_of(item)),
lab_panel(item),
])The default key preserves the normal singleton path todo/panel. Rendering a
second instance gives it an independent component runtime scope:
todo_panel(todo_panel_state_of(item), key = "compact")That key isolates local stores, effects, listeners, and DOM effect markers.
Domain data is still shared when both instances receive values derived from the
same application model, just as two controlled React components can receive the
same props. feature_root(...) installs opaque ambient identity, so descendants
derive a sibling VDOM key with feature_key() and a DOM effect marker with
feature_marker(name) without receiving panel_key props. The default instance
keeps its established browser names; additional instances receive a
group-prefixed name automatically.
Key segments use collision-free URI encoding. Existing non-empty slugs and numeric IDs keep their established runtime paths. Snapshots created with the older ambiguous encoding for empty, underscore-leading, or reserved-character keys fall back to the component's initial value once; current application keys are unaffected.
Persistent features and ordinary keyed children deliberately have different unmount behavior:
| Boundary | When it is not rendered | State policy |
|---|---|---|
component(...) / components(...) |
its current feature still renders | release the child state branch |
feature_root(...) |
the whole feature is absent | retain the feature snapshot |
reset_feature(...) |
integration explicitly resets a feature | release the complete feature branch |
Each rendered feature records which ordinary child scopes it visited. At the end of that feature render, old child markers that were not visited are swept together with their state and effect metadata. If the feature itself is not rendered—for example after changing routes—no sweep runs for it, so its state can still return after route navigation, HMR, or a reload.
This means filtering an item out of a still-mounted feature has normal unmount semantics and clears that item's local state. Domain reducers only remove domain entities; they do not reconstruct component paths for cleanup.
The complete lifecycle contract, marker format, migration behavior, and usage
guidance are documented in
docs/component-lifecycle.md.
Only the app integration boundary installs the runtime:
run_component(
item,
group = "app",
key = "root",
render = fn(owner) render_layout(owner, results))The explicit group + key remains intentional. Unlike React, an ordinary Koka
function call does not create a fiber identity that the runtime can recover
implicitly across list reordering or hot replacement. Keeping feature roots
absolute also preserves existing snapshot paths when a panel moves in the
layout.
The single runtime collects listeners and scheduled effects in component evaluation order (shell, active feature tree, then overlay). Components should not use cross-component effect ordering as a data dependency.
Component-local state uses one reducer-backed store rather than several direct setters. A store combines:
- a state type;
- a serializable action type;
- one pure update function;
- one explicit recovery choice at the store definition.
pub struct task_editor_state(editing : bool, draft : string)
pub type task_editor_action
Begin_edit(title : string)
Change_draft(value : string)
Finish_edit
Cancel_edit(title : string)
fun reduce_task_editor(current : task_editor_state, action : task_editor_action)
match action
Begin_edit(title) -> Task_editor_state(True, title)
Change_draft(value) -> Task_editor_state(True, value)
Finish_edit -> current(editing = False)
Cancel_edit(title) -> Task_editor_state(False, title)
pub val task_editor_store : store_spec<task_editor_state,task_editor_action> = replay_store(
name = "editor",
action_codec = Action_codec(
schema = "todo/task-editor-action",
version = 1,
decode = decode_task_editor_action,
encode = encode_task_editor_action),
replay = fn(action) {
match action
Begin_edit(_) -> Replay_start
Change_draft(_) -> Replay_replace("draft")
Finish_edit -> Replay_reset
Cancel_edit(_) -> Replay_reset
},
reduce = reduce_task_editor)Store definitions use labelled fields deliberately. name owns runtime
identity, action_codec owns the observable wire action, and reduce is the
pure transition. replay declares a bounded editor session: begin replaces an
old session, repeated draft changes replace the same stable slot, and
finish/cancel return to the call site's latest initial value.
Use snapshot_store(...) when state cannot be safely represented by
Replay_start / Replay_replace(slot) / Replay_reset. Counters, arbitrary
toggle history, and accumulative collections normally keep an explicit state
codec. The runtime never truncates arbitrary actions because doing so can
change reducer semantics.
The component-facing call mirrors React's reducer pair:
val (Task_editor_state(editing, draft), dispatch) = use_store(
task_editor_store,
initial = Task_editor_state(False, item.title))Controls can emit typed store actions without manually reading or writing the runtime tree:
input_text(
draft,
input = on_store_input(
"draft-input",
action = Change_draft,
dispatch = dispatch))
button(
"Expand",
click = on_store_click(
"toggle-expanded",
action = Toggle_incident,
dispatch = dispatch))The required codecs are defined once beside the store. They are not passed
through every component call. Explicit scope/path/tree access is reserved for
framework and testing code. See
docs/store-recovery.md for the recovery decision,
replay modes, migration behavior, and complete examples.
Feature render and panel APIs return only vnode. The app boundary owns the
runtime tree through runtime_frame, and one run_component(...) pass collects
state, scheduled effects, and listeners for the full tree. Parent views also avoid inspecting child stores;
state needed by a parent should be promoted to domain state instead of read
back from a child's local cell.
Simple named state primitives still exist for experiments, but new business components should prefer a typed store when state can be changed by user events. This keeps updates action-shaped, observable, and compatible with future agent-driven action/store tooling.
When a domain action needs a current component value, the component puts that value into the serializable action. The domain workflow never looks the child store up by scope:
val save_edit = action_store_transition(
Save_task(draft),
dispatch = dispatch,
store_action = Finish_edit,
store = dispatch_editor,
store_when = draft != "")
button("Save", click = on_local_click("save-edit", save_edit))action_store_transition(...) is intentionally independent of the DOM event,
so the same transition can be registered for both click and Enter. It always
sends the complete domain action first. store_when controls only whether the
single component-store action follows; it never suppresses the domain intent.
The domain action remains complete enough for inspection, persistence, or a
future agent to submit directly.
Use this builder only for the repeated one-domain-action/one-store-action shape.
Keep on_local_* for direct model updates, multiple local actions, or branching
that cannot be stated as one store_when condition. See
docs/action-store-transitions.md for the
decision guide, ordering contract, and complete examples.
State and listener identity use stable component scopes. Listeners add an event kind and a semantic name, for example:
on_action_click("set-done", action = Set_filter("done"), dispatch = dispatch)
on_action_input("change-query", action = Change_search_query, dispatch = dispatch)
on_action_enter("add-task", action = Add_task, dispatch = dispatch)
action_store_transition(Save_task(draft), dispatch = dispatch, store_action = Finish_edit, store = dispatch_editor, store_when = draft != "")
on_local_input("draft-input", fn(value, owner) ...)When an event only sends a typed domain action, on_action_click(...) and
on_action_input(...) / on_action_enter(...) keep the action constructor,
semantic listener name, and dispatch function visible without repeating a
forwarding closure in every element. on_local_* remains the escape hatch for
handlers with custom branching or direct model updates.
At render time run_event_registry(...) collects typed Koka callbacks and
returns small listener tokens to the VDOM. render_node(...) serializes those
tokens into data-k-click, data-k-input, or data-k-enter. The JavaScript
host only delegates DOM events back to the current Koka registry.
The registry reports duplicate listener ids and semantic drift. This makes conditional rendering safer without relying on listener call order.
Store listeners are intentionally a narrower convenience layer:
on_store_click(...)emits one typed local action through the store dispatch;on_store_input(...)converts the input string into one typed local action;on_action_*dispatches typed domain actions and can still expose reducer effects;action_store_transition(...)builds one event-independent handler that sends a domain action and then optionally one component-store action;on_local_*remains available when an event needs custom component logic.
Domain actions and component-store actions now cross the same observation
boundary. Each dispatch emits an action_envelope containing only serializable
data:
Action_envelope(
source = "domain", // or "component"
target = "todo/tasks/2",
schema = "todo/task-action",
version = 2,
payload = "e")source describes ownership, target identifies the domain or component
scope, and schema + version + payload are owned by the typed action codec. A
single user event may emit more than one envelope. For example, starting a Todo
edit emits the domain intent first and then the component editor-store action.
The observation point is an Algebraic Effect. capture_actions(...) gives
tests and other hosts the ordered envelopes without coupling reducers to a
logger. The browser runtime uses run_runtime_action_observed(...) to log the
same stream, while run_runtime_action(...) deliberately handles and discards
it for callers that do not need observation.
This observation stream is an intent log, not a general replay engine. Dispatch
is observed before the reducer/workflow runs, so an action remains visible even
when confirmation rejects it. A component may explicitly choose
replay_store(...); that store persists only its own scoped pure actions and
does not consume the observation stream. Replaying actions that invoke browser
or service effects still needs a separate policy for permissions,
deduplication, and recorded responses.
The component runtime tree is encoded with a respo/runtime-snapshot|1
top-level header followed by versioned state_entry values. The envelope
version owns the transport format; every entry still owns its stable path,
schema, version, and payload. Restore behavior is defensive:
- the decoder still accepts the legacy headerless four-field format;
- unknown or malformed envelope versions safely restore an empty runtime tree;
- a malformed entry is skipped without discarding other valid entries;
- unknown schemas and unsupported versions fall back to the store's initial value;
- component state is restored only when its keyed scope and store schema still match;
- replay stores use
respo/replay:<action-schema>entries and rebuild state from the currentinitialplus decoded component actions; respo/component-scopemetadata preserves ordinary-child ownership across HMR/reload so stale child branches can be swept on the next feature render.
src/main.js keeps using the existing
koka-respo:component-state:v1 localStorage key so legacy values remain
discoverable; future wire-format evolution belongs to the snapshot envelope.
Writes are coalesced with
requestAnimationFrame, then flushed synchronously at the important
boundaries:
- before accepting a replacement Koka runtime;
- during Vite HMR disposal;
- on
pagehide.
The replacement runtime boots from that snapshot, so transient component state
usually survives generated JavaScript replacement. A normal page load restores
the last snapshot from localStorage.
This is a best-effort development and recovery mechanism, not a persistence contract for domain data. Domain data should still have its own application storage and migration strategy.
Algebraic Effects are most useful here as capability boundaries, not as a way to hide the component state tree.
Browser or service operations can be declared directly:
pub effect fun confirm_action(message : string) : bool
pub effect fun wait_ms(delay : int) : ()
pub effect val current_operator : stringReducers and workflows expose those requirements in their effect rows. The browser installs production handlers; tests install deterministic handlers for confirmation, time, network-like responses, audit, and context values. The business flow itself does not need a mock-specific rewrite.
For local state, effects provide the runtime read/write capability, while the
public component API remains use_store plus typed actions. Using effects
alone would not solve identity, serialization, versioning, or HMR restoration;
the typed store and snapshot layers handle those concerns.
JavaScript remains intentionally thin:
- delegated DOM events;
- DOM mounting and patch application;
- hash reads and writes;
- snapshot persistence and HMR hand-off;
- browser-only capabilities such as confirmation and wall-clock access.
View description, store reducers, registry dispatch, state-tree operations, diffing, and patch planning remain in Koka.
app.kk: small exported browser bridge.explore/react/core.kk: VDOM types and flattened element constructors.explore/react/action.kk: serializable action codecs, envelopes, and the observation effect.explore/react/state.kk: component scopes, typed stores, listeners, effects, and lifecycle authoring.explore/react/runtime.kk: host-only registered callback execution, scheduled effects, and snapshot transport.explore/react/inspection.kk: read-only VDOM/event-registry/runtime queries for tests and devtools.explore/react/renderer.kk: rendering, diffing, and patch planning.docs/quick-start.mdanddocs/component-authoring.md: progressive component tutorial and the compact preferred API.demo/*: application shell, features, actions, stores, and workflows.demo/runtimeframe.kk: pairs the domain model with the framework runtime tree and runs render/action transitions at the app boundary.runtime/*: DOM and system FFI only.src/main.js: Vite host, event bridges, HMR, and snapshot persistence.
Recommended reading order:
explore/react/core.kkexplore/react/action.kkexplore/react/state.kkdemo/todo/state.kkdemo/todo/view.kkdemo/lab/state.kkdemo/lab/view.kkdemo/runtimeframe.kkapp.kkandsrc/main.js
The project uses Yarn Berry and expects Koka to be available on PATH.
yarn dev
yarn test:koka
yarn buildyarn devcompiles Koka, then starts Vite.yarn test:kokaruns the Koka-side regression suite without a browser.yarn buildcompiles Koka and performs the production Vite build.
The current experiment combines three ideas:
- React-like plain function components and keyed identity.
- Reducer-backed, serializable local stores for component interaction.
- A shared serializable observation stream for domain and component actions.
- Algebraic Effects for explicit runtime and environment capabilities.
The useful question is not whether this can reproduce React API-for-API. It is whether typed effects and serializable actions can make component boundaries, testing, hot replacement, and agent-driven state changes easier to reason about without making feature code noisy.