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.
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 |
|---|---|
| function component | a plain function returning vnode |
| keyed child component | components(items, group=..., key=..., render=...) |
| component boundary | component_root(...) / component_in(...) |
| local reducer | use_store(spec, initial=...) |
| local dispatch | binding.send(action) or on_store_* |
| app reducer/action | typed feature actions dispatched through the app runtime |
useEffect-like hook |
state_effect(name=..., deps=..., 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:
article([
strong(item.title, class = "incident-title"),
button(
if expanded then "Collapse" else "Expand",
class = "button",
click = on_store_click("toggle-expanded", local, Toggle_incident)),
input_text(
draft,
class = "input incident-input",
input = on_store_input("draft-input", local, Change_incident_draft),
placeholder = "Local reply draft..."),
], key = "incident-" ++ iid.show, class = "incident-card")For repeated children, components(...) owns the keyed component boundary:
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.
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;
- versioned codecs used by the runtime and snapshot boundary.
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 update_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)The component-facing call stays small:
val editor = use_store(
task_editor_store,
initial = Task_editor_state(False, item.title))
val Task_editor_state(editing, draft) = editor.currentControls can emit typed store actions without manually reading or writing the runtime tree:
input_text(
draft,
input = on_store_input("draft-input", editor, Change_draft))
button(
"Expand",
click = on_store_click("toggle-expanded", local, Toggle_incident))The codecs are defined once beside the store. They are not passed through every component call. Explicit scope/path/tree access is reserved for framework code and the small amount of feature coordination that must address a component outside its render function.
Feature render and panel APIs do not receive or return the runtime tree. The app
boundary owns it through runtime_frame, and run_runtime_render(...) threads
it through component runners. 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, replayable, and compatible with future agent-driven action/store tooling.
State and listener identity use stable component scopes. Listeners add an event kind and a semantic name, for example:
on_local_click("save-edit", fn(owner) ...)
on_local_input("draft-input", fn(value, owner) ...)
on_local_enter("send-reply", fn(owner) ...)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;on_store_input(...)converts the input string into one typed local action;on_local_*remains available when the event also changes the owner model or invokes other effects.
The component runtime tree is encoded as versioned state_entry values. Each
entry records its stable path, schema, version, and payload. Restore behavior is
defensive:
- unknown schemas and unsupported versions fall back to the store's initial value;
- malformed snapshot data is ignored instead of reaching a component decoder;
- component state is restored only when its keyed scope and store schema still match.
src/main.js persists the snapshot under
koka-respo:component-state:v1. 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/state.kk: component scopes, typed stores, listeners, effects, snapshots, and runtime handlers.explore/react/renderer.kk: rendering, diffing, and patch planning.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/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.
- 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.