Skip to main content

What Are Hooks?

Hooks are lightweight event subscribers in BlueLibs Runner. They listen for events and react when those events are emitted. Unlike tasks, hooks are optimized for side effects and don’t return meaningful values. Think of hooks as “listeners” or “handlers” — when an event happens, hooks spring into action.

Why Use Hooks?

Hooks solve the problem of reacting to events without coupling the emitter to the listener. They let you:
  • Send notifications when users register
  • Track analytics when orders complete
  • Update caches when data changes
  • Trigger workflows when critical events occur
Hooks vs Tasks: Tasks are for business logic that returns a result. Hooks are for side effects that react to events. Use tasks when you need a return value, hooks when you’re responding to something that already happened.

Creating Your First Hook

Here’s a simple hook that logs when a user registers:
import { r, globals } from "@bluelibs/runner";

const userRegistered = r
  .event("app.events.userRegistered")
  .build();

const logUserRegistration = r
  .hook("app.hooks.logUserRegistration")
  .on(userRegistered)
  .dependencies({ logger: globals.resources.logger })
  .run(async (event, { logger }) => {
    await logger.info(`User registered: ${event.data.userId}`);
  })
  .build();
1

Create the hook

Use r.hook(id) with a unique namespaced ID like "app.hooks.logUserRegistration".
2

Specify which event to listen to

Call .on(event) to specify which event triggers this hook.
3

Add dependencies

Use .dependencies({ ... }) to inject services the hook needs.
4

Define the handler

Call .run(async (event, deps) => { ... }) to define what happens when the event fires.
5

Build and register

Build the hook and register it in your app resource.

Hooks with Dependencies

Hooks can depend on resources, tasks, or other services:
import { r } from "@bluelibs/runner";

const mailer = r
  .resource("app.mailer")
  .init(async () => ({
    send: async (to: string, subject: string, body: string) => {
      console.log(`Email to ${to}: ${subject}`);
    },
  }))
  .build();

const sendWelcomeEmail = r
  .hook("app.hooks.sendWelcomeEmail")
  .on(userRegistered)
  .dependencies({ mailer })
  .run(async (event, { mailer }) => {
    await mailer.send(
      event.data.email,
      "Welcome!",
      "Thanks for joining our platform!"
    );
  })
  .build();
Dependencies are automatically injected as the second parameter to .run(). TypeScript infers the types, so you get full autocomplete.

Listening to Multiple Events

Use onAnyOf() to listen to several events with a single hook:
import { r } from "@bluelibs/runner";
import { onAnyOf } from "@bluelibs/runner/defs";

const userRegistered = r.event("app.events.userRegistered").build();
const userLoggedIn = r.event("app.events.userLoggedIn").build();

const trackUserActivity = r
  .hook("app.hooks.trackUserActivity")
  .on(onAnyOf(userRegistered, userLoggedIn))
  .dependencies({ analytics })
  .run(async (event, { analytics }) => {
    await analytics.track(event.definition.id, event.data);
  })
  .build();
onAnyOf() preserves type inference. The event parameter will be typed as a union of all event payloads.

Hook Execution Order

Hooks execute in order of registration by default. Control the order explicitly with .order():
const criticalHook = r
  .hook("app.hooks.critical")
  .on(userRegistered)
  .order(1) // Runs first
  .run(async (event) => {
    console.log("Critical: Save to database");
  })
  .build();

const normalHook = r
  .hook("app.hooks.normal")
  .on(userRegistered)
  .order(10) // Runs later
  .run(async (event) => {
    console.log("Normal: Send notification");
  })
  .build();

const lowPriorityHook = r
  .hook("app.hooks.lowPriority")
  .on(userRegistered)
  .order(100) // Runs last
  .run(async (event) => {
    console.log("Low priority: Update analytics");
  })
  .build();
Lower numbers run first. Use this to ensure critical operations complete before optional ones.

Stopping Propagation

A hook can stop propagation to prevent downstream hooks from running:
const validateUserHook = r
  .hook("app.hooks.validateUser")
  .on(userRegistered)
  .order(1)
  .run(async (event) => {
    if (!isValidEmail(event.data.email)) {
      event.stopPropagation(); // Stop other hooks
      throw new Error("Invalid email address");
    }
  })
  .build();

const sendWelcomeEmail = r
  .hook("app.hooks.sendWelcomeEmail")
  .on(userRegistered)
  .order(10)
  .run(async (event, { mailer }) => {
    // This won't run if validation fails
    await mailer.send(event.data.email, "Welcome!", "Thanks for joining!");
  })
  .build();
When to stop propagation: Use it for validation hooks or circuit breakers. Once you call stopPropagation(), no hooks with higher order numbers will run.

Wildcard Hooks: Listen to Everything

Create a wildcard hook to listen to all events in your application:
import { r, globals } from "@bluelibs/runner";

const auditAllEvents = r
  .hook("app.hooks.auditAllEvents")
  .on("*") // Wildcard: listen to all events
  .dependencies({ logger: globals.resources.logger })
  .run(async (event, { logger }) => {
    await logger.info(`Event: ${event.definition.id}`, {
      data: event.data,
      timestamp: new Date(),
    });
  })
  .build();
Use wildcard hooks for:
  • Audit logs: Track all events in the system
  • Debugging: Log every event during development
  • Monitoring: Send all events to an observability platform
Exclude events from wildcard hooks: Tag events with globals.tags.excludeFromGlobalHooks to prevent them from triggering wildcard hooks.

Parallel Execution

By default, hooks run sequentially. Enable parallel execution on the event:
const userRegistered = r
  .event("app.events.userRegistered")
  .parallel(true) // Enable parallel execution
  .build();

const sendWelcomeEmail = r
  .hook("app.hooks.sendWelcomeEmail")
  .on(userRegistered)
  .order(10)
  .run(async (event, { mailer }) => {
    await mailer.send(event.data.email, "Welcome!", "...");
  })
  .build();

const trackAnalytics = r
  .hook("app.hooks.trackAnalytics")
  .on(userRegistered)
  .order(10) // Same order as sendWelcomeEmail
  .run(async (event, { analytics }) => {
    await analytics.track("user.registered", event.data);
  })
  .build();
With .parallel(true):
  • Hooks with the same order run concurrently
  • Different order groups run sequentially
  • If one hook fails, all hooks in the batch complete, then an AggregateError is thrown
When to use parallel execution: Use it for independent side effects like notifications and analytics. Avoid it for operations that depend on each other or share mutable state.

Error Handling

Hooks can fail, and you control how those failures are handled:
// Throws immediately when a hook fails
await userRegistered({ userId: "123", email: "[email protected]" });
Use aggregate mode when you want all side effects to attempt execution, even if some fail. Use fail-fast when any failure should halt processing.

Real-World Example: User Registration Hooks

Here’s a complete example showing multiple hooks reacting to a single event:
src/users/events.ts
import { r } from "@bluelibs/runner";
import { z } from "zod";

export const userRegistered = r
  .event("app.events.userRegistered")
  .payloadSchema(
    z.object({
      userId: z.string(),
      email: z.string().email(),
      name: z.string(),
    })
  )
  .build();
src/notifications/hooks/welcome-email.hook.ts
import { r } from "@bluelibs/runner";
import { userRegistered } from "../../users/events";
import { mailer } from "../resources/mailer.resource";

export const sendWelcomeEmail = r
  .hook("app.hooks.sendWelcomeEmail")
  .meta({
    title: "Send Welcome Email",
    description: "Sends a welcome email to newly registered users",
  })
  .on(userRegistered)
  .order(10)
  .dependencies({ mailer })
  .run(async (event, { mailer }) => {
    await mailer.send({
      to: event.data.email,
      subject: "Welcome to Our Platform!",
      body: `Hi ${event.data.name}, thanks for joining us!`,
    });
  })
  .build();
src/analytics/hooks/track-registration.hook.ts
import { r } from "@bluelibs/runner";
import { userRegistered } from "../../users/events";
import { analytics } from "../resources/analytics.resource";

export const trackRegistration = r
  .hook("app.hooks.trackRegistration")
  .meta({
    title: "Track User Registration",
    description: "Sends registration event to analytics platform",
  })
  .on(userRegistered)
  .order(20)
  .dependencies({ analytics })
  .run(async (event, { analytics }) => {
    await analytics.track("user.registered", {
      userId: event.data.userId,
      timestamp: new Date(),
    });
  })
  .build();
src/slack/hooks/notify-team.hook.ts
import { r } from "@bluelibs/runner";
import { userRegistered } from "../../users/events";
import { slack } from "../resources/slack.resource";

export const notifyTeam = r
  .hook("app.hooks.notifyTeam")
  .meta({
    title: "Notify Team on Slack",
    description: "Posts a Slack message when a new user registers",
  })
  .on(userRegistered)
  .order(30)
  .dependencies({ slack })
  .run(async (event, { slack }) => {
    await slack.post({
      channel: "#new-users",
      text: `New user registered: ${event.data.name} (${event.data.email})`,
    });
  })
  .build();
All three hooks run independently. If the Slack hook fails, the email and analytics hooks still run (assuming you use failureMode: "aggregate").

Hooks vs Tasks: When to Use What?

Use Hooks When…Use Tasks When…
Reacting to an eventPerforming business logic
Side effects (emails, logs, analytics)Returning a result
Multiple independent actionsSingle focused action
Order matters but results don’tYou need the return value
Fire-and-forget behaviorAwaiting the outcome

Best Practices

1

Use descriptive IDs

Follow the pattern domain.hooks.actionName (e.g., "notifications.hooks.sendWelcomeEmail").
2

Add metadata

Always set .meta({ title, description }) for documentation.
3

Keep hooks focused

One hook should do one thing. Create multiple hooks instead of one complex hook.
4

Use order wisely

Only set explicit order when it matters. Otherwise, rely on registration order.
5

Handle errors gracefully

Hooks should not crash the entire app. Use try-catch for non-critical operations.
  • Events - Hooks listen to events
  • Tasks - Tasks can emit events that trigger hooks
  • Lifecycle - Built-in lifecycle events like ready and shutdown

Build docs developers (and LLMs) love