HypenHypen
Modules

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 (.hypen file) — 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, onCreated only runs the first time a route is visited in a session. Subsequent navigations to the same route reuse the cached module instance and fire onActivated instead. If you need data refreshed on every navigation, put it in onActivated.

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 → onDestroyed

Routes 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

  1. Keep state minimal — Only store what the UI needs
  2. Use TypeScript interfaces — Define state shape for type safety
  3. Handle async properly — Show loading states during fetches
  4. Name actions descriptivelysaveDocument not handleClick
  5. Use .bind() for forms — Two-way binding avoids boilerplate action handlers

See Also