LLM Reference

LLM Reference: Hooks System

This is a dense reference for LLMs working with Vextro’s hooks system. It documents every hook type, their exact signatures, execution order, context objects, and the internal hook map builders used at module initialization.

HookContext

type HookContext = Record<string, unknown>;

A mutable plain object shared between all hooks during a single mutation or query invocation. Use it to pass computed values between hooks without re-querying. For example, a collection beforeChange hook can store a value in context that a field afterChange hook reads later in the same pipeline.

The context is created fresh for each invocation — it does not persist across requests.


Collection Hooks

Collection hooks operate on the entire document payload. They fire during document mutations (create, update, delete) and queries (read).

CollectionHookContext

The base context spread into all mutation-side collection hooks:

type CollectionHookContext<DataModel> = {
  ctx: GenericMutationCtx<DataModel>;
  collection: AdminCollection<string>;
  collectionSlug: string;
  context: HookContext;
};
  • ctx — the Convex mutation context (database access, auth, scheduling)
  • collection — the full admin collection definition (fields, config, access rules)
  • collectionSlug — the string identifier for this collection (e.g. "posts")
  • context — the shared mutable HookContext for this invocation

BeforeChangeHook

Runs before a create or update write. Return a modified data object to replace what gets written, or return void to pass through unchanged. Throw to abort the mutation.

type BeforeChangeHook<DataModel> = (args: {
  data: Record<string, Value>;
  originalDoc?: Record<string, Value>;
  operation: "create" | "update";
} & CollectionHookContext<DataModel>) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;
  • data — the document data about to be written
  • originalDoc — the existing document (present for "update", absent for "create")
  • operation — "create" or "update"

AfterChangeHook

Runs after the database write completes. Return a patch object to trigger a second write that merges the patch into the document, or return void for side effects only.

type AfterChangeHook<DataModel> = (args: {
  doc: Record<string, Value>;
  previousDoc?: Record<string, Value>;
  operation: "create" | "update";
} & CollectionHookContext<DataModel>) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;
  • doc — the document as written to the database
  • previousDoc — the document before the write (present for "update", absent for "create")
  • operation — "create" or "update"

BeforeDeleteHook

Runs before a document is deleted. Cannot modify data. Throw an error to abort the deletion.

type BeforeDeleteHook<DataModel> = (args: {
  id: string;
  doc: Record<string, Value>;
} & CollectionHookContext<DataModel>) => Promise<void> | void;
  • id — the document ID about to be deleted
  • doc — the full document about to be deleted

AfterDeleteHook

Runs after a document has been deleted. Side effects only (logging, cleanup, cache invalidation).

type AfterDeleteHook<DataModel> = (args: {
  id: string;
  doc: Record<string, Value>;
} & CollectionHookContext<DataModel>) => Promise<void> | void;
  • id — the document ID that was deleted
  • doc — the full document that was deleted (snapshot taken before deletion)

BeforeReadHook

Runs on query before locale merge and field-level afterRead hooks. Return a modified doc to transform the raw document, or void to pass through.

Note: read hooks receive GenericQueryCtx (read-only), not GenericMutationCtx.

type BeforeReadHook<DataModel> = (args: {
  doc: Record<string, Value>;
  context: HookContext;
  ctx: GenericQueryCtx<DataModel>;
  collection: AdminCollection<string>;
  collectionSlug: string;
}) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;

AfterReadHook

Runs after all field afterRead hooks and locale merge have completed. Return a modified doc or void.

type AfterReadHook<DataModel> = (args: {
  doc: Record<string, Value>;
  context: HookContext;
  ctx: GenericQueryCtx<DataModel>;
  collection: AdminCollection<string>;
  collectionSlug: string;
}) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;

CollectionHooks Type

The full shape of the hooks object on a collection definition:

type CollectionHooks<DataModel> = {
  beforeChange?: Array<BeforeChangeHook<DataModel>>;
  afterChange?: Array<AfterChangeHook<DataModel>>;
  beforeDelete?: Array<BeforeDeleteHook<DataModel>>;
  afterDelete?: Array<AfterDeleteHook<DataModel>>;
  beforeRead?: Array<BeforeReadHook<DataModel>>;
  afterRead?: Array<AfterReadHook<DataModel>>;
};

Each hook type accepts an array of functions. They execute in registration order (first in the array runs first).


Field-Level Hooks

Field hooks operate on individual field values rather than the full document. They are defined inline on field definitions.

FieldHookContext

The base context spread into all field hooks:

type FieldHookContext = {
  fieldName: string;
  collection: AdminCollection<string>;
  collectionSlug: string;
  context: HookContext;
};
  • fieldName — the name of the field this hook is attached to
  • collection — the parent collection definition
  • collectionSlug — the parent collection slug
  • context — the shared mutable HookContext

FieldBeforeChangeHook

Runs before the database write. Transform a single field value. Return a new value to replace what gets written, or return void/undefined to pass through unchanged.

type FieldBeforeChangeHook<DataModel> = (args: {
  value: Value | undefined;
  originalValue?: Value | undefined;
  data: Record<string, Value>;
  originalDoc?: Record<string, Value>;
  operation: "create" | "update";
  ctx: GenericMutationCtx<DataModel>;
} & FieldHookContext) =>
  | Promise<Value | undefined | void>
  | Value
  | undefined
  | void;
  • value — the current value for this field
  • originalValue — the previous value (present for "update")
  • data — the full document data being written (read-only reference; modify via return value)
  • originalDoc — the full original document (present for "update")
  • operation — "create" or "update"
  • ctx — Convex mutation context

FieldAfterReadHook

Runs after the document is read from the database, during query-time transformation. Transform a single field value for output.

type FieldAfterReadHook<DataModel> = (args: {
  value: Value | undefined;
  doc: Record<string, Value>;
  ctx: GenericQueryCtx<DataModel>;
} & FieldHookContext) =>
  | Promise<Value | undefined | void>
  | Value
  | undefined
  | void;
  • value — the stored value for this field
  • doc — the full document
  • ctx — Convex query context (read-only)

FieldAfterChangeHook

Runs after the database write completes. Side effects only — cannot modify the written data.

type FieldAfterChangeHook<DataModel> = (args: {
  value: Value | undefined;
  previousValue: Value | undefined;
  doc: Record<string, Value>;
  operation: "create" | "update";
  fieldName: string;
  context: HookContext;
  ctx: GenericMutationCtx<DataModel>;
  collection: AdminCollection<string>;
  collectionSlug: string;
}) => Promise<void> | void;
  • value — the value as written
  • previousValue — the value before the write
  • doc — the full document after write

FieldBeforeDuplicateHook

Runs when a document is being duplicated, before the duplicated document is inserted. Transform the copied field value. Return a new value or void to keep the original.

type FieldBeforeDuplicateHook<DataModel> = (args: {
  value: Value | undefined;
  siblingData: Record<string, Value>;
  fieldName: string;
  context: HookContext;
  ctx: GenericMutationCtx<DataModel>;
  collection: AdminCollection<string>;
  collectionSlug: string;
}) =>
  | Promise<Value | undefined | void>
  | Value
  | undefined
  | void;
  • value — the field value being copied from the source document
  • siblingData — the full data object of the document being created (all fields)

FieldHooks Type

The full shape of the hooks object on a field definition:

type FieldHooks<DataModel> = {
  beforeChange?: Array<FieldBeforeChangeHook<DataModel>>;
  afterRead?: Array<FieldAfterReadHook<DataModel>>;
  afterChange?: Array<FieldAfterChangeHook<DataModel>>;
  beforeDuplicate?: Array<FieldBeforeDuplicateHook<DataModel>>;
};

Note: there is no beforeRead at the field level. Use the collection-level beforeRead hook to transform the raw document before field hooks run.


Global Hooks

Global hooks follow the same pattern as collection hooks but operate on singleton global documents. The context uses global and globalSlug instead of collection and collectionSlug.

GlobalBeforeChangeHook

type GlobalBeforeChangeHook<DataModel> = (args: {
  data: Record<string, Value>;
  originalDoc?: Record<string, Value>;
  ctx: GenericMutationCtx<DataModel>;
  global: AdminGlobal;
  globalSlug: string;
  context: HookContext;
}) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;

GlobalAfterChangeHook

type GlobalAfterChangeHook<DataModel> = (args: {
  doc: Record<string, Value>;
  previousDoc?: Record<string, Value>;
  ctx: GenericMutationCtx<DataModel>;
  global: AdminGlobal;
  globalSlug: string;
  context: HookContext;
}) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;

GlobalBeforeReadHook

type GlobalBeforeReadHook<DataModel> = (args: {
  doc: Record<string, Value>;
  ctx: GenericQueryCtx<DataModel>;
  global: AdminGlobal;
  globalSlug: string;
  context: HookContext;
}) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;

GlobalAfterReadHook

type GlobalAfterReadHook<DataModel> = (args: {
  doc: Record<string, Value>;
  ctx: GenericQueryCtx<DataModel>;
  global: AdminGlobal;
  globalSlug: string;
  context: HookContext;
}) =>
  | Promise<Record<string, Value> | void>
  | Record<string, Value>
  | void;

GlobalHooks Type

type GlobalHooks<DataModel> = {
  beforeChange?: Array<GlobalBeforeChangeHook<DataModel>>;
  afterChange?: Array<GlobalAfterChangeHook<DataModel>>;
  beforeRead?: Array<GlobalBeforeReadHook<DataModel>>;
  afterRead?: Array<GlobalAfterReadHook<DataModel>>;
};

Globals do not have beforeDelete or afterDelete hooks because globals are singletons and are not deleted.

Global Field Hooks

Fields on global definitions support the same FieldHooks type as collection fields (beforeChange, afterRead, afterChange, beforeDuplicate). The collectionSlug in the field hook context is replaced by the global slug, and collection is replaced by the global definition.


Execution Order

Create/Update Pipeline

1. Field beforeChange hooks (per field, in definition order)
2. Collection beforeChange hooks (in array order)
3. Database write (insert or patch)
4. Field afterChange hooks (per field, in definition order)
5. Collection afterChange hooks (in array order)

For update operations, originalDoc and originalValue/previousValue are populated. For create operations, they are undefined.

If a beforeChange hook (field or collection) returns a value, that value replaces the data for subsequent hooks and the write. If it returns void, the data passes through unchanged.

If an afterChange hook returns a value, it is used as a patch to update the document with a second write.

Duplicate Pipeline

1. Field beforeDuplicate hooks (per field)
2. Collection beforeChange hooks (operation: "create")
3. Database insert
4. Field afterChange hooks (per field)
5. Collection afterChange hooks (operation: "create")

The beforeDuplicate hooks run first to transform copied values (e.g., appending ” (Copy)” to titles, clearing unique slugs). After that, the standard create pipeline runs.

Delete Pipeline

1. Collection beforeDelete hooks (throw to abort)
2. Database delete
3. Collection afterDelete hooks

There are no field-level delete hooks. If you need per-field cleanup on delete, use a collection beforeDelete or afterDelete hook and access the field values from the doc argument.

Read Pipeline

1. Collection beforeRead hooks (raw doc from database)
2. Locale merge (for localized collections)
3. Field afterRead hooks (per field, in definition order)
4. Collection afterRead hooks (final transformed doc)

Read hooks receive GenericQueryCtx (read-only context) rather than GenericMutationCtx. They cannot write to the database.

Global Pipelines

Globals follow the same execution order as collections for change and read pipelines, substituting global/globalSlug for collection/collectionSlug. There is no delete pipeline for globals.


Usage in Collection Definition

import { defineVextroCollection, f } from "vextro";

export const posts = defineVextroCollection({
  slug: "posts",
  labels: { singular: "Post", plural: "Posts" },
  hooks: {
    beforeChange: [
      async ({ data, operation, ctx }) => {
        if (operation === "create") {
          return { ...data, createdAt: Date.now() };
        }
      },
    ],
    afterChange: [
      async ({ doc, operation }) => {
        if (operation === "update") {
          // Send notification, invalidate cache, etc.
        }
      },
    ],
    beforeDelete: [
      async ({ doc, ctx }) => {
        if (doc.status === "published") {
          throw new Error("Cannot delete a published post");
        }
      },
    ],
  },
  fields: {
    name: f.text({
      hooks: {
        beforeChange: [({ value }) => String(value ?? "").trim()],
        afterRead: [({ value }) => String(value ?? "").toUpperCase()],
        beforeDuplicate: [({ value }) => (value ? `${value} (Copy)` : value)],
      },
    }),
    slug: f.text({
      unique: true,
      hooks: {
        beforeDuplicate: [() => ""], // Clear slug on duplicate
      },
    }),
  },
});

Usage in Global Definition

import { defineVextroGlobal, f } from "vextro";

export const siteSettings = defineVextroGlobal({
  slug: "siteSettings",
  label: "Site Settings",
  hooks: {
    afterChange: [
      async ({ doc, ctx }) => {
        // Invalidate site-wide cache
      },
    ],
  },
  fields: {
    siteName: f.text({
      hooks: {
        beforeChange: [({ value }) => String(value ?? "").trim()],
      },
    }),
  },
});

Using HookContext for Cross-Hook Communication

export const orders = defineVextroCollection({
  slug: "orders",
  hooks: {
    beforeChange: [
      async ({ data, context, ctx }) => {
        // Compute something expensive once
        const customer = await ctx.db.get(data.customerId);
        context.customer = customer;
        context.isVip = customer?.totalOrders > 100;
      },
    ],
    afterChange: [
      async ({ doc, context }) => {
        // Access the value computed in beforeChange
        if (context.isVip) {
          // Apply VIP processing
        }
      },
    ],
  },
  fields: {
    discount: f.number({
      hooks: {
        beforeChange: [
          ({ value, context }) => {
            // Field hook can also read from the shared context
            if (context.isVip) {
              return Math.max(value ?? 0, 10); // Minimum 10% for VIPs
            }
            return value;
          },
        ],
      },
    }),
  },
});

Important Notes

  • Bulk operations skip hooks. bulkUpdateStatus, bulkDeleteDocuments, and bulkCreateDocuments do NOT trigger any hooks. They write directly to the database for performance. If you need hook-like behavior for bulk operations, implement it in your calling code.
  • All hooks share a single mutable HookContext. The context object is created per invocation and passed to every hook in the pipeline. Mutations and reads each get their own context.
  • Hook arrays execute in order. The first function in the array runs first. Use this to control sequencing when multiple hooks depend on each other.
  • Returning void means no change. For transform hooks (beforeChange, afterRead, beforeDuplicate), returning void or undefined passes the original value through unchanged. Only return a value when you intend to replace data.
  • Returning a value from beforeChange replaces the data. The returned object becomes the new data for subsequent hooks and the database write.
  • Returning a value from afterChange patches the document. The returned object is merged into the document via a second database write.
  • beforeDelete can abort deletion. Throw an error to prevent the delete from proceeding. The mutation will roll back (Convex transactions are atomic).
  • Read hooks are read-only. beforeRead and afterRead receive GenericQueryCtx, which cannot write to the database. Use them only for transforming output.
  • Hooks run server-side in Convex. They execute inside Convex functions, not in HTTP middleware or browser code. Collection mutation hooks run in mutations (transactional). Read hooks run in queries (non-transactional, read-only).
  • No beforeRead at the field level. Use the collection-level beforeRead to transform the raw document before field-level afterRead hooks run.
  • Globals have no delete hooks. Globals are singletons and cannot be deleted, so beforeDelete and afterDelete do not exist on GlobalHooks.
Previous
Authentication