Functions

Lifecycle Hooks

beforeInvoke, afterInvoke, onSuccess, onError

Lifecycle Hooks allow you to run code at different stages of query/mutation execution - perfect for logging, metrics, validation, and more.

Available Hooks

beforeInvoke

Runs before the handler executes:

const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.find(args.id));
  },
})
.beforeInvoke(async (ctx, args) => {
  console.log(`Fetching user ${args.id}`);
});

afterInvoke

Runs after the handler completes (always runs):

const createUser = t.mutation({
  args: z.object({ name: z.string() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.create(args));
  },
})
.afterInvoke(async (ctx, args, result) => {
  // Runs regardless of success or failure
  console.log(`Create user completed:`, result.isSuccess());
});

onSuccess

Runs only on success:

const deleteUser = t.mutation({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id);
    return success({ id: args.id });
  },
})
.onSuccess(async (ctx, args, data) => {
  // Send notification only if deletion succeeded
  await ctx.emailService.send(args.id, "Your account has been deleted");
});

onError

Runs only on error:

const updatePost = t.mutation({
  args: z.object({ id: z.number(), title: z.string() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.update(args.id, args));
  },
})
.onError(async (ctx, args, error) => {
  // Log errors
  await ctx.logger.error("Failed to update post", { error, args });
});

Execution Order

Hooks execute in this order:

  1. beforeInvoke - Before handler
  2. Handler - Your main logic
  3. onSuccess OR onError - Based on result
  4. afterInvoke - Always runs at the end
const procedure = t.mutation({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    console.log("2. Handler executing");
    return success({ id: args.id });
  },
})
.beforeInvoke(async (ctx, args) => {
  console.log("1. Before invoke");
})
.onSuccess(async (ctx, args, data) => {
  console.log("3. On success");
})
.onError(async (ctx, args, error) => {
  console.log("3. On error");
})
.afterInvoke(async (ctx, args, result) => {
  console.log("4. After invoke");
});

Chaining Hooks

Chain multiple hooks of the same type:

const createUser = t.mutation({
  args: z.object({ name: z.string() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.create(args));
  },
})
.beforeInvoke(async (ctx, args) => {
  console.log("Starting user creation");
})
.beforeInvoke(async (ctx, args) => {
  await validateRateLimit(ctx.userId);
})
.onSuccess(async (ctx, args, data) => {
  await ctx.cache.invalidate("users");
})
.onSuccess(async (ctx, args, data) => {
  await ctx.analytics.track("user_created", { userId: data.id });
});

Use Cases

Logging

const query = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.find(args.id));
  },
})
.beforeInvoke(async (ctx, args) => {
  ctx.logger.info("Query starting", { args });
})
.afterInvoke(async (ctx, args, result) => {
  ctx.logger.info("Query completed", {
    success: result.isSuccess(),
    duration: Date.now() - startTime,
  });
});

Metrics

const mutation = t.mutation({
  args: z.object({ name: z.string() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.create(args));
  },
})
.beforeInvoke(async (ctx, args) => {
  ctx.metrics.startTimer("mutation_duration");
})
.afterInvoke(async (ctx, args, result) => {
  ctx.metrics.record("mutation_duration");
  ctx.metrics.increment("mutations_total", {
    success: result.isSuccess(),
  });
});

Validation

const updatePost = t.mutation({
  args: z.object({
    id: z.number(),
    title: z.string(),
  }),
  handler: async (ctx, args) => {
    return success(await ctx.db.update(args.id, args));
  },
})
.beforeInvoke(async (ctx, args) => {
  // Custom validation before handler
  const post = await ctx.db.find(args.id);
  if (!post) {
    throw new Error("Post not found");
  }
  if (post.authorId !== ctx.userId) {
    throw new Error("Unauthorized");
  }
});

Caching

const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    return success(await ctx.db.find(args.id));
  },
})
.beforeInvoke(async (ctx, args) => {
  // Check cache first
  const cached = await ctx.cache.get(`user:${args.id}`);
  if (cached) {
    return success(cached); // Won't execute handler
  }
})
.onSuccess(async (ctx, args, data) => {
  // Cache successful result
  await ctx.cache.set(`user:${args.id}`, data, 300);
});

Error Handling

const deleteAccount = t.mutation({
  args: z.object({ userId: z.number() }),
  handler: async (ctx, args) => {
    await ctx.db.delete(args.userId);
    return success(undefined);
  },
})
.onError(async (ctx, args, error) => {
  // Send alert on error
  await ctx.alerts.send({
    severity: "high",
    message: "Account deletion failed",
    error: error.message,
    userId: args.userId,
  });
})
.afterInvoke(async (ctx, args, result) => {
  // Log regardless of outcome
  await ctx.logger.audit("account_deletion_attempt", {
    userId: args.userId,
    success: result.isSuccess(),
  });
});

Best Practices

1. Use Hooks for Cross-Cutting Concerns

// ✅ Good - hooks for cross-cutting logic
const procedure = t.mutation({
  handler: async (ctx, args) => {
    // Business logic only
    return success(await ctx.db.create(args));
  },
})
.beforeInvoke(logStart)
.beforeInvoke(checkRateLimit)
.afterInvoke(logEnd)
.onSuccess(invalidateCache);

// ❌ Bad - mixing concerns in handler
const procedure = t.mutation({
  handler: async (ctx, args) => {
    logStart();
    checkRateLimit();
    const result = await ctx.db.create(args);
    invalidateCache();
    logEnd();
    return success(result);
  },
});

2. Keep Hooks Simple

// ✅ Good - simple, focused hooks
.beforeInvoke(async (ctx, args) => {
  await validateRateLimit(ctx.userId);
})

// ❌ Bad - complex logic in hooks
.beforeInvoke(async (ctx, args) => {
  const rateLimit = await ctx.redis.get(`rate:${ctx.userId}`);
  const user = await ctx.db.find(ctx.userId);
  const settings = await ctx.db.getSettings(user.plan);
  if (rateLimit > settings.limit) {
    throw new Error("Rate limit exceeded");
  }
  // ... 20 more lines
})

3. Don't Modify Args in Hooks

// ❌ Bad - mutating args
.beforeInvoke((ctx, args) => {
  args.timestamp = Date.now(); // Mutation!
})

// ✅ Good - use context
.beforeInvoke((ctx, args) => {
  ctx.metadata.startTime = Date.now();
})

See Also

On this page