HypenHypen
Guide

Error Handling

Catching and recovering from errors in modules with onError

Error Handling

Modules can register an .onError() handler to catch errors from action handlers and lifecycle hooks in a single place. This gives you control over logging, recovery, and error propagation.

The onError Handler

Register an error handler on your module definition:

import { app } from "@hypen-space/core";

export default app
  .defineState({ count: 0 })
  .onAction("increment", async ({ state }) => {
    if (state.count >= 100) throw new Error("Count limit reached");
    state.count++;
  })
  .onError(({ error, actionName, lifecycle, state }) => {
    console.error(`Error in ${actionName ?? lifecycle}:`, error.message);
    return { handled: true }; // suppress the error
  });

Without .onError(), the default behavior is to log the error and emit a global "error" event. With it, you decide what happens.

Error Context

The error handler receives an ErrorContext object:

FieldTypeDescription
errorHypenErrorThe error that occurred
stateTCurrent state (for inspection)
actionNamestring?Set if the error came from an action handler
lifecyclestring?Set if the error came from a lifecycle hook ("created" or "destroyed")

actionName and lifecycle are mutually exclusive — one will be set depending on where the error originated.

Handler Results

Return a result object to control what happens next:

Return ValueEffect
void / undefinedDefault behavior (log + emit global error event)
{ handled: true }Error is suppressed, no further propagation
{ rethrow: true }Error is re-thrown to the caller
{ retry: true }Retry the operation (actions only)
.onError(({ error, actionName }) => {
  if (error.message.includes("network")) {
    return { retry: true }; // retry network failures
  }
  if (error.message.includes("limit")) {
    return { handled: true }; // swallow known limits
  }
  // return nothing → default log + emit
})

What Gets Caught

.onError() catches errors from:

  • Action handlers — exceptions thrown in .onAction() callbacks
  • onCreated — exceptions thrown in the .onCreated() lifecycle hook
  • onDestroyed — exceptions thrown in the .onDestroyed() lifecycle hook
export default app
  .defineState({ data: null, error: null })
  .onCreated(async (state) => {
    // If this throws, onError catches it with lifecycle: "created"
    const res = await fetch("/api/init");
    state.data = await res.json();
  })
  .onAction("refresh", async ({ state }) => {
    // If this throws, onError catches it with actionName: "refresh"
    const res = await fetch("/api/data");
    state.data = await res.json();
  })
  .onError(({ error, actionName, lifecycle, state }) => {
    if (lifecycle === "created") {
      state.error = "Failed to initialize";
    } else if (actionName) {
      state.error = `Action "${actionName}" failed: ${error.message}`;
    }
    return { handled: true };
  });

Error Types

The engine provides typed error classes that you can check in your handler:

import { ParseError, RenderError, StateError, ActionError } from "@hypen-space/core";

.onError(({ error }) => {
  if (error instanceof ActionError) {
    console.error(`Action failed: ${error.actionName}`);
  } else if (error instanceof StateError) {
    console.error("State mutation error:", error.message);
  }
})
Error ClassWhen
ActionErrorAn action handler threw an exception
ParseErrorThe engine failed to parse a component template
RenderErrorThe engine failed during reconciliation/rendering
StateErrorA state mutation or notification failed

Go SDK

The Go SDK has the same .OnError() pattern:

counter := core.NewAppBuilder(map[string]any{"count": 0}, nil).
    OnAction("increment", func(ctx core.ActionHandlerContext) {
        count := ctx.State.Get("count").(int)
        if count >= 100 {
            panic("count limit reached")
        }
        ctx.State.Set("count", count+1)
    }).
    OnError(func(ctx core.ErrorContext) *core.ErrorHandlerResult {
        if ctx.ActionName != "" {
            fmt.Printf("Action %q failed: %v\n", ctx.ActionName, ctx.Error)
        } else if ctx.Lifecycle != "" {
            fmt.Printf("Lifecycle %q failed: %v\n", ctx.Lifecycle, ctx.Error)
        }
        return &core.ErrorHandlerResult{Handled: true}
    }).
    Build()

In Go, action handler panics are recovered and routed through OnError. The ErrorContext fields:

FieldTypeDescription
ErrorerrorThe error (or recovered panic)
State*ObservableStateCurrent state
ActionNamestringSet if error came from an action handler
Lifecyclestring"created" or "destroyed" if from a lifecycle hook

Return &ErrorHandlerResult{Handled: true} to suppress, {Rethrow: true} to re-panic, or nil for default behavior (log + emit error event).

Kotlin SDK

val counter = app.defineState(mapOf("count" to 0))
    .onAction("increment") { ctx ->
        val count = ctx.state.get("count") as Int
        if (count >= 100) error("count limit reached")
        ctx.state.set("count", count + 1)
    }
    .onError { ctx ->
        when {
            ctx.actionName != null ->
                println("Action '${ctx.actionName}' failed: ${ctx.error.message}")
            ctx.lifecycle != null ->
                println("Lifecycle '${ctx.lifecycle}' failed: ${ctx.error.message}")
        }
        ErrorHandlerResult.Handled // suppress the error
    }
    .build()

Return ErrorHandlerResult.Handled to suppress, ErrorHandlerResult.Rethrow to re-throw, or null for default behavior (log to stderr).

Best Practices

Use Error State for UI Feedback

Combine .onError() with error state to show feedback in the UI:

interface MyState {
  data: Item[] | null;
  error: string | null;
  isLoading: boolean;
}

export default app
  .defineState<MyState>({ data: null, error: null, isLoading: false })
  .onAction("load", async ({ state }) => {
    state.isLoading = true;
    state.error = null;
    const res = await fetch("/api/items");
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    state.data = await res.json();
    state.isLoading = false;
  })
  .onError(({ error, state }) => {
    state.error = error.message;
    state.isLoading = false;
    return { handled: true };
  });
module Items {
    Column {
        When(${state.error}) {
            Text("${state.error}")
                .color("#EF4444")
        }
        When(${state.isLoading}) {
            Text("Loading...")
        }
        ForEach(${state.data}, key: "id") {
            Text("${item.name}")
        }
    }
}

Keep the Error Handler Simple

The error handler should not throw. If it does, the error is logged and default behavior runs.

Don't Swallow Everything

Only return { handled: true } for errors you can genuinely recover from. Let unexpected errors propagate so they surface during development:

.onError(({ error, actionName }) => {
  if (error.message.includes("network") || error.message.includes("timeout")) {
    return { handled: true }; // recoverable
  }
  // unexpected errors: fall through to default (log + emit)
})

Next Steps