Modules
Stateful module system for Hypen applications
Modules
Modules are the stateful building blocks of Hypen applications. They combine UI templates with TypeScript state management.
Overview
A module consists of:
- Template (
.hypenfile) — The declarative UI - Definition (TypeScript) — State, actions, and lifecycle handlers
module Counter {
Column {
Text("Count: @{state.count}")
Button { Text("+1") }
.onClick(@actions.increment)
}
}import { app } from "@hypen-space/core";
export default app
.defineState({ count: 0 })
.onAction("increment", async ({ state }) => {
state.count++;
});API Reference
defineState
Sets the initial state with TypeScript generics for type safety:
interface MyState {
items: string[];
loading: boolean;
}
app.defineState<MyState>({
items: [],
loading: false
})onCreated
Called once, when the module is first constructed. Use it for one-time setup that shouldn't repeat on every re-entry — building caches, setting up long-lived listeners, or fetching data that should outlive the screen's visibility.
.onCreated(async (state, context) => {
// Access router
if (context?.router) {
context.router.push("/home");
}
})Note: Under
ManagedRouter's default (persistent) behavior,onCreatedonly runs the first time a route is visited in a session. Subsequent navigations to the same route reuse the cached module instance and fireonActivatedinstead. If you need data refreshed on every navigation, put it inonActivated.
onActivated
Called every time the module becomes the active route target — once right
after onCreated on first mount, and again each time the user navigates back
to a persisted module. This is the right place for per-visit work like
refreshing data, (re)connecting ephemeral subscriptions, or starting timers.
.onActivated(async (state) => {
state.loading = true;
state.items = await fetchItems();
state.loading = false;
})onDeactivated
Called every time the module stops being the active route target. Runs
before the module is cached for persistence (when the user navigates away)
OR before onDestroyed if the module is being torn down. Use this to pause
timers, tear down ephemeral subscriptions, or snapshot transient UI state.
.onDeactivated((state) => {
clearInterval(state.pollHandle);
})onDestroyed
Called when the module is torn down for good (e.g. ManagedRouter.stop(), or
when persist: false is set on the definition). Receives (state, context?):
.onDestroyed((state, context) => {
state.connection?.close();
})Module Persistence & Lifecycle Order
When a module is used via ManagedRouter, it persists across navigations by
default — navigating away caches the instance so that navigating back
restores its state (eliminating the "loading flash" you'd otherwise see on
every re-entry). Opt out per module with { persist: false }:
app.defineState({ count: 0 }, { name: "Counter", persist: false });Lifecycle order on navigation:
First visit: construct → onCreated → onActivated
Navigate away: onDeactivated → (cached in memory)
Revisit (cached): onActivated
Final teardown: onDeactivated → onDestroyedRoutes that don't resolve to a module definition have nothing to persist and are unchanged by this behavior.
onAction
Handler for UI actions. Receives a context object with action details, state, and global context:
.onAction<PayloadType>("actionName", async ({ action, state, context }) => {
// action.name - the action name
// action.payload - event data (typed as PayloadType)
// action.sender - component that dispatched the action
// state - current state (auto-synced via Proxy)
// context - GlobalContext for cross-module communication
// context.router - HypenRouter for navigation
})onError
Error handler called when any action or lifecycle hook throws:
.onError(({ error, actionName, lifecycle, state }) => {
console.error(`Error in ${actionName ?? lifecycle}:`, error.message);
return { handled: true }; // suppress error
// or: { rethrow: true } // re-throw
// or: { retry: true } // retry (actions only)
// or: void // default: log + emit
})See Error Handling Guide for details.
onDisconnect / onReconnect / onExpire
Session lifecycle hooks for remote apps. These handle client disconnections and reconnections:
.onDisconnect(({ state, session }) => {
// Client disconnected, but session is alive (within TTL)
})
.onReconnect(({ session, restore }) => {
// Client reconnected with existing session
restore(savedState);
})
.onExpire(({ session }) => {
// Session TTL expired
})See the TypeScript SDK for details.
persist
Attach a state store for automatic persistence across sessions:
import { durableObjectStore, session, global, withKey } from "@hypen-space/cf";
// Per-session persistence
app.defineState({ draft: "" })
.persist(durableObjectStore(session()))
// Global (shared by all users)
app.defineState({ messages: [] })
.persist(durableObjectStore(global()))
// Keyed to a state value (e.g., user ID after login)
app.defineState({ user: null, todos: [] })
.persist(durableObjectStore(withKey(state => state.user?.id)))On restore, stored state is merged over initialState: new fields get defaults, existing fields are restored.
See the Persistence Guide for details.
build
Finalizes the module definition. Required when passing to RemoteServer.module() or HypenModuleInstance:
const myModule = app
.defineState({ count: 0 })
.onAction("increment", ({ state }) => { state.count++; })
.build();ui
For single-file components, embed the template directly using the hypen tagged template literal:
import { app, hypen, state } from "@hypen-space/core";
export default app
.defineState({ count: 0 })
.onAction("increment", ({ state }) => { state.count++; })
.ui(hypen`
Column {
Text("Count: @{state.count}")
Button { Text("+") }
.onClick(@actions.increment)
}
`);The .ui() method calls .build() internally and returns the finalized module definition.
State Management
State is tracked via Proxy — mutations sync automatically:
.onAction<{ item: Item }>("addItem", async ({ action, state }) => {
state.items.push(action.payload!.item);
// UI updates automatically — no callback needed
})Deep Nesting
Nested object mutations are tracked automatically:
.onAction("updateAddress", ({ state }) => {
state.user.profile.address.city = "New York";
// Tracked at path "user.profile.address.city"
})Batch Updates
Multiple synchronous mutations are coalesced into a single UI update. For explicit batching:
import { batchStateUpdates } from "@hypen-space/core";
.onAction("bulkUpdate", ({ state }) => {
batchStateUpdates(state, () => {
state.items = newItems;
state.count = newItems.length;
});
})Best Practices
- Keep state minimal — Only store what the UI needs
- Use TypeScript interfaces — Define state shape for type safety
- Handle async properly — Show loading states during fetches
- Name actions descriptively —
saveDocumentnothandleClick - Use
.bind()for forms — Two-way binding avoids boilerplate action handlers
See Also
- State & Modules Guide — Detailed tutorial with examples
- Error Handling — Error recovery patterns
- TypeScript SDK — Complete SDK reference including component discovery, RemoteServer, and deployment
- Components — All built-in components