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 writtenoriginalDoc— 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 databasepreviousDoc— 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 deleteddoc— 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 deleteddoc— 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 tocollection— the parent collection definitioncollectionSlug— the parent collection slugcontext— 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 fieldoriginalValue— 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 fielddoc— the full documentctx— 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 writtenpreviousValue— the value before the writedoc— 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 documentsiblingData— 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, andbulkCreateDocumentsdo 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), returningvoidorundefinedpasses 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.
beforeReadandafterReadreceiveGenericQueryCtx, 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
beforeReadto transform the raw document before field-levelafterReadhooks run. - Globals have no delete hooks. Globals are singletons and cannot be deleted, so
beforeDeleteandafterDeletedo not exist onGlobalHooks.