State & Modules
Managing state and defining actions with TypeScript modules
State & Modules
Modules are stateful components that manage their own state and handle actions. They are the building blocks for creating interactive, data-driven UI in Hypen.
What is a Module?
A module combines:
- State: Reactive data that the UI depends on
- Actions: Functions that modify state in response to user interactions
- Template: The Hypen UI definition that renders the state
The key difference between a module and a regular component is that a module's state persists as long as the module exists, and it can respond to actions that modify that state.
Defining a Module
The UI Template
In your .hypen file, define the module's UI:
module Counter {
Column {
Text("Count: ${state.count}")
.fontSize(32)
.fontWeight("bold")
Row {
Button { Text("-") }
.onClick(@actions.decrement)
.backgroundColor("#EF4444")
.padding(16)
.borderRadius(8)
Spacer()
Button { Text("+") }
.onClick(@actions.increment)
.backgroundColor("#22C55E")
.padding(16)
.borderRadius(8)
}
.gap(24)
}
.padding(32)
.horizontalAlignment("center")
}The Module Definition (TypeScript)
Define the module's state and actions in TypeScript:
// Counter.ts
import { app } from "@hypen-space/core";
interface CounterState {
count: number;
}
export default app
.defineState<CounterState>({ count: 0 })
// Lifecycle handler: receives (state, context?)
.onCreated(async (state, context) => {
console.log("Counter module created");
})
// Action handler: receives context object { action, state, next, context }
.onAction("increment", async ({ state }) => {
state.count++;
})
.onAction("decrement", async ({ state }) => {
state.count--;
})
// Lifecycle handler: receives (state, context?)
.onDestroyed((state, context) => {
console.log("Counter module destroyed");
});State
Defining State
State is defined using defineState<T>(initialValue):
interface UserState {
user: {
name: string;
email: string;
} | null;
isLoading: boolean;
error: string | null;
}
export default app
.defineState<UserState>({
user: null,
isLoading: false,
error: null
});Accessing State in Templates
Use the ${state.path} syntax to bind state values to your UI:
module Profile {
Column {
// Simple binding
Text("Welcome, ${state.user.name}!")
// Nested paths
Text("Email: ${state.user.email}")
// In string interpolation
Text("Status: ${state.isLoading ? 'Loading...' : 'Ready'}")
}
}State Updates
State is automatically tracked through a Proxy. Mutate the state object and changes sync automatically:
.onAction("updateProfile", async ({ action, state }) => {
state.user = {
name: action.payload.name,
email: action.payload.email
};
// State changes auto-sync - triggers re-render with new state
})Actions
Actions are functions that respond to user interactions and modify state.
Defining Actions
// Action handler receives a context object with all parameters
.onAction("actionName", async ({ action, state, next, context }) => {
// action - contains name, payload, sender
// state - current state (mutable, auto-synced via Proxy)
// next.router - HypenRouter for programmatic navigation
// context - GlobalContext for cross-module communication
})Dispatching Actions from UI
Use @actions.actionName via event applicators in your templates:
// Simple action
Button { Text("Save") }
.onClick(@actions.save)
// Action with payload
Button { Text("Delete") }
.onClick(@actions.deleteItem, itemId: ${state.selectedId})Action Payloads
Actions automatically receive payload data from the event and any additional arguments:
.onAction("deleteItem", async ({ action, state }) => {
const itemId = action.payload.itemId;
state.items = state.items.filter(item => item.id !== itemId);
})Async Actions
Actions support async operations like network requests:
.onAction("fetchUser", async ({ action, state }) => {
state.isLoading = true;
// State changes sync automatically, showing loading state
try {
const response = await fetch(`/api/users/${action.payload.userId}`);
const user = await response.json();
state.user = user;
state.error = null;
} catch (error) {
state.error = "Failed to fetch user";
state.user = null;
} finally {
state.isLoading = false;
// Final state syncs automatically
}
})Lifecycle
Modules have lifecycle hooks for setup and cleanup.
onCreated
Called when the module is first mounted. Receives (state, context?):
.onCreated(async (state, context) => {
// Initialize data
const savedData = localStorage.getItem('formData');
if (savedData) {
state.formData = JSON.parse(savedData);
}
// State changes sync automatically
})onDestroyed
Called when the module is unmounted. Receives (state, context?):
.onDestroyed((state, context) => {
// Cleanup subscriptions, timers, etc.
clearInterval(state.timerId);
})Complete Example: Todo List
Template (TodoList.hypen)
module TodoList {
Column {
Text("Todo List")
.fontSize(24)
.fontWeight("bold")
Row {
Input(placeholder: "Add a new todo...")
.value(${state.newTodo})
.onInput(@actions.updateNewTodo)
.onKey(@actions.addTodo)
.weight(1)
.padding(12)
.borderRadius(8)
Button { Text("Add") }
.onClick(@actions.addTodo)
.backgroundColor("#3B82F6")
.padding(horizontal: 16, vertical: 12)
.borderRadius(8)
}
.gap(12)
List { }
.marginTop(16)
}
.padding(24)
.maxWidth(600)
}Module (TodoList.ts)
import { app } from "@hypen-space/core";
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
newTodo: string;
filter: "all" | "active" | "completed";
}
export default app
.defineState<TodoState>({
todos: [],
newTodo: "",
filter: "all"
})
.onCreated(async (state, context) => {
const saved = localStorage.getItem("todos");
if (saved) {
state.todos = JSON.parse(saved);
}
})
.onAction("updateNewTodo", async ({ action, state }) => {
state.newTodo = action.payload.value;
})
.onAction("addTodo", async ({ state }) => {
if (state.newTodo.trim()) {
state.todos.push({
id: Date.now().toString(),
text: state.newTodo.trim(),
completed: false
});
state.newTodo = "";
saveTodos(state.todos);
}
})
.onAction("toggleTodo", async ({ action, state }) => {
const todo = state.todos.find(t => t.id === action.payload.id);
if (todo) {
todo.completed = !todo.completed;
saveTodos(state.todos);
}
})
.onAction("deleteTodo", async ({ action, state }) => {
state.todos = state.todos.filter(t => t.id !== action.payload.id);
saveTodos(state.todos);
});
function saveTodos(todos: Todo[]) {
localStorage.setItem("todos", JSON.stringify(todos));
}Best Practices
Keep State Minimal
Only store data that the UI needs. Derived data should be computed when needed.
// Good: Store raw data
defineState({ items: [], searchQuery: "" })
// Avoid: Storing computed data
defineState({ items: [], searchQuery: "", filteredItems: [] })State Changes Sync Automatically
State mutations are tracked via Proxy and sync automatically:
.onAction("update", async ({ action, state }) => {
state.value = action.payload.value;
// Changes sync automatically - no callback needed
})Handle Async Operations Properly
Show loading states and handle errors:
.onAction("loadData", async ({ state }) => {
state.isLoading = true;
state.error = null;
// Loading state syncs automatically
try {
const data = await fetchData();
state.data = data;
} catch (e) {
state.error = "Failed to load data";
} finally {
state.isLoading = false;
// Final state syncs automatically
}
})Use Descriptive Action Names
Name actions by what they do, not how they're triggered:
// Good
.onAction("saveDocument", ...)
.onAction("toggleDarkMode", ...)
// Avoid
.onAction("handleClick", ...)
.onAction("buttonPressed", ...)Platform Support
Modules work identically across all platforms:
- Web: Runs in browser with WASM engine
- Android: Native Compose rendering
- iOS: Native rendering
The same module definition works on all platforms, with platform-specific rendering handled automatically by the Hypen runtime.
Next Steps
- Components - All built-in components
- Styling - Complete applicator reference
- Inputs - Forms and user interaction