ADR 032 — `.vox.ui` reactive modules
ADR 032: .vox.ui reactive modules
Section titled “ADR 032: .vox.ui reactive modules”Status
Section titled “Status”Accepted (2026-05-03). Phase D shipped end-to-end across four commits in the same session:
FileKind::from_pathhelper at crates/vox-compiler/src/module.rs (commit954ad8775).parse_with_kindparser entry,Parser.file_kindfield,Decl::ReactiveModule(ReactiveModuleDecl)AST variant, and the parser dispatch that absorbs module-scopestate/derived/effect/on mount/on cleanupinto a syntheticReactiveModuleDecl(commit26c90f9be).HirReactiveModuleHIR node + AST→HIR lowering in hir/lower/mod.rs and emit at codegen_ts/reactive_module_emit.rs producing one<Name>Provider.tsxper module (typedValueinterface + Context + Provider +use<Name>()hook), wired intoemitter::generate(commita16417db0).
Remaining sub-slices for a future cycle:
- CLI build entry points should pass the source-file basename through
parse_with_kindso the parser fillsReactiveModuleDecl.name(today the codegen falls back to aReactiveModule<index>default per the §“Module name” note). - Cross-module reactive imports — recognizing imports from
.vox.uimodules as reactive bindings inside the Phase E dep analyzer (§“Read-tracking interaction”). The analyzer already supports per-modulestate_names; the wiring is “include.vox.uiexports in the visible-bindings set.”
Context
Section titled “Context”Vox today supports reactive members (state name: T = init, derived name = expr, effect: { … }, on mount: { … }, on cleanup: { … }) inside component { } blocks only. The grammar accepts these tokens at top level inside a ReactiveComponentDecl member list (crates/vox-compiler/src/parser/descent/decl/head.rs:253–334) and lowers them to React useState / useMemo / useEffect (crates/vox-codegen/src/codegen_ts/reactive.rs:740–815). A working golden lives at examples/golden/reactive_counter.vox.
There is no module-scope analog. Authors who need shared state across multiple components must either:
- Lift the state into a parent component and prop-drill (verbose, fragile against refactors).
- Drop into hand-written React
useContextvia the React-hook bridge (crates/vox-compiler/src/react_bridge.rs) (loses the reactive-member ergonomics). - Pull in a third-party state-management library (out-of-scope for the Vox idiom).
Comparator framework: Svelte 6 solves the same problem with .svelte.ts files (“universal reactivity” — runes work outside components). Detailed competitive analysis in the Svelte vs React frameworks research doc.
Decision
Section titled “Decision”Introduce a .vox.ui file-suffix convention that authorizes module-scope reactive members and lowers them to a generated React context provider + a typed use<Name>() hook.
Suffix and scope
Section titled “Suffix and scope”- Suffix:
.vox.ui(matches the precedent of suffixed file conventions established by.generated.mdand.voxignore-derived.cursorignore/.aiignore/.aiexcludefiles documented in AGENTS.md §Auto-generated documentation files). - Allowed top-level decls in
.vox.uifiles: all decls legal in regular.voxfiles (type,fn,component,routes,state_machine, etc.) plus module-scope reactive members:state,derived,effect,on mount,on cleanup. - Disallowed in regular
.voxfiles: module-scope reactive members. The current parser already enforces this implicitly (the tokens are only accepted insidefinish_reactive_component_after_name); the rule becomes explicit and surfaced as a diagnostic.
Lowering
Section titled “Lowering”Each .vox.ui file emits one TSX module exporting:
- A typed React
Contextwhose value is the module’s reactive bindings. - A
<NameProvider>component that owns the underlyinguseState/useMemo/useEffectcalls (mirroring the existing reactive-component lowering at reactive.rs:740–815). - A
useName()hook that consumes the context (typed against the module’s exported reactive bindings).
Where Name is derived from the file basename (e.g., counter.vox.ui → CounterProvider + useCounter()).
Cross-module imports:
// vox:skip — illustrativestate count: int = 0derived double = count * 2
// app.voximport { count, double } from "./counter.vox.ui"
component App() { view: <p>"count = {count}, ×2 = {double}"</p>}In TSX emit, the import desugars to a useCounter() call inside the consuming component. Read-tracking analysis (state_deps.rs) must learn that imports from .vox.ui modules produce reactive bindings (Phase E ties this in).
File-discovery wire-up
Section titled “File-discovery wire-up”The current Vox toolchain has no single dispatch point for file extensions — CLI commands accept paths directly without extension validation. This ADR commits to extending the following entry points to recognize the .vox.ui suffix and select the reactive-module parser variant:
crates/vox-cli/src/commands/build.rs—run()/run_frontend()crates/vox-cli/src/commands/check.rscrates/vox-cli/src/commands/dev.rs—vox-compilerdJSON-RPC dev daemoncrates/vox-cli/src/commands/mcp_server/— relevant forvox_validate_file/vox_validate_sourcesource discriminationcrates/vox-lsp/src/lib.rs— LSP file-type detection
Implementation strategy: introduce a single vox_compiler::module::FileKind::from_path(path) helper that all entry points call, instead of duplicating extension-matching logic. The helper returns FileKind::Source | FileKind::ReactiveModule | FileKind::Unknown, and downstream code branches on the kind.
Read-tracking interaction
Section titled “Read-tracking interaction”Reactive bindings imported from a .vox.ui module must be visible to the auto-dep inference pass (state_deps.rs) when the consuming component declares derived or effect that reference them. The extract_state_deps() walker’s state_names set must include the imported bindings; the loader needs to emit those imports as part of the reactive-binding namespace.
This is a hard dependency between Phase D (this ADR) and Phase E (cross-call dep inference). Phase D landing first means Phase E can import-aware-track from day one; Phase E landing first means Phase D can wire imports into the existing analyzer.
Versioning
Section titled “Versioning”.vox.ui is added at Vox 0.5.x. No deprecation of any existing surface. Regular .vox files continue to behave exactly as before.
Alternatives considered
Section titled “Alternatives considered”-
Allow module-scope reactive members in regular
.voxfiles (no suffix). Rejected: makes the.voxgrammar context-sensitive (a module-levelstate count = 0would mean different things depending on whether the module is consumed by acomponent). The suffix is a load-bearing signal that the file participates in the React-context lifecycle. -
Use a
module reactive { … }block instead of a file-suffix convention. Rejected: per AGENTS.md §Grammar Unification, new bare keywords are reserved for genuinely new scope semantics. A reactive module file is a packaging concept, not a new scope kind. Adding amodule reactivekeyword would fight the grammar policy. -
Make module-scope reactive members opt-in via a top-of-file pragma (e.g.,
#![reactive_module]). Rejected: pragmas are not currently part of the Vox grammar; introducing them for one feature is a worse precedent than a file-suffix convention. -
Borrow
.svelte.tsexactly. Rejected: the.tssubstring would make the suffix ambiguous against a future.vox.tsinterop story..vox.uiis unambiguous.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Authors can express shared reactive state without lifting it into a parent component or pulling a third-party state library.
- Matches Svelte 6’s
.svelte.tsmodel — familiar to developers coming from that ecosystem. - Centralizes file-extension dispatch into one helper, paying down a tech-debt item (no current single dispatch point).
- Consuming components remain plain
.voxfiles with a normalimportstatement — the seam is honest.
Negative
Section titled “Negative”- Adds a new file kind to learn (small docs cost).
- Cross-module reactive read-tracking ties Phase D and Phase E together more tightly than the original implementation plan suggested.
- The generated TSX includes a
<NameProvider>per.vox.uimodule; large apps with many reactive modules will accumulate provider nesting. Mitigation: emit a single composed root provider when multiple modules are imported.
Neutral
Section titled “Neutral”- No effect on the React-hook bridge (react_bridge.rs) — bridge stays as escape hatch.
- No effect on the Phase 5 React interop spec or external frontend interop plan.
.vox.uimodules emit ordinary React; they’re consumable by external React code via the same Phase 5 npm-publishing path.
Implementation references
Section titled “Implementation references”Concrete file changes documented in the Svelte-Mineable Features Implementation Plan §Phase D. Summary:
- New AST node
ReactiveModuleat crates/vox-compiler/src/ast/decl/ui.rs. - Parser change at crates/vox-compiler/src/parser/descent/mod.rs gated on
FileKind::ReactiveModule. - New
vox_compiler::module::FileKind::from_path(path)helper. - Codegen extension at crates/vox-codegen/src/codegen_ts/reactive.rs emitting the provider+hook pair.
- Goldens at
examples/golden/counter.vox.uiandexamples/golden/counter_consumer.vox.