Hypen
Guide

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