Hooks
Run custom logic at every stage of the document lifecycle — on the server and in the Admin UI.
Hooks let you intercept reads, writes, and deletes to transform data, enforce rules, send notifications, or drive reactive Admin UI behaviour. There are two distinct kinds:
- Server hooks — run on the backend, defined on collections, globals, or individual fields.
- Admin UI hooks — run in the browser inside the Admin form, defined per field.
Server Hooks
Collection hooks
import { defineCollection } from '@dyrected/core'
export const Posts = defineCollection({
slug: 'posts',
hooks: {
beforeRead: [logAccess],
afterRead: [formatDates],
beforeChange: [generateSlug, validateDependencies],
afterChange: [sendWebhook, revalidateCache],
beforeDelete: [checkReferences],
afterDelete: [cleanupMedia],
beforeTransition: [requireCommentOnReject],
afterTransition: [notifyPublisher],
},
fields: [...],
})| Hook | When it runs | Arguments | Return value |
|---|---|---|---|
beforeRead | Before the database query | { req, query, user } | Modified where query, or void |
afterRead | After fetching, before the response | { doc, req, user } | Modified doc |
beforeChange | Before insert or update | { data, doc, req, user, operation } | Modified data object |
afterChange | After the write is committed | { doc, previousDoc, req, user, operation } | void (return value ignored) |
beforeDelete | Before the record is removed | { id, doc, req, user } | void — throw to abort |
afterDelete | After the record is removed | { id, doc, req, user } | void |
beforeTransition | Before a workflow state transition | { id, doc, req, user, transition, from, to } | void — throw to abort |
afterTransition | After a workflow state transition | { id, doc, req, user, transition, from, to } | void |
Notes on specific arguments:
operation— present onbeforeChangeandafterChange: either'create'or'update'.doconbeforeChange— the existing document when updating;undefinedon create.previousDoconafterChange— snapshot of the document before the write. Use it to detect what changed.queryonbeforeRead— the currentwherefilter; return a different object to override it.
Hook Lifecycle & Capabilities
The following table summarizes the runtime capabilities of each collection hook, showing which hooks support asynchronous execution, database reads, database writes, and external side effects (like sending emails or invoking webhooks).
| Hook | Async | DB reads | DB writes | Side effects (email, fetch) |
|---|---|---|---|---|
beforeRead | ✓ | ✓ (read-only) | ✗ | ✗ |
afterRead | ✓ | ✓ (read-only) | ✗ | ✗ |
beforeChange | ✓ | ✓ (read-only) | ✗ | ✓ (external only) |
afterChange | ✓ | ✓ | ✓ | ✓ (email, webhooks, capacity updates) |
afterDelete | ✓ | ✓ | ✓ | ✓ |
beforeTransition | ✓ | ✓ (read-only) | ✗ | ✗ |
afterTransition | ✓ | ✓ | ✓ | ✓ (email, webhooks) |
If you find yourself building an API route just to trigger a side effect after a save, check if afterChange covers it first. afterChange receives the full writable database adapter context. It is the correct place for emails, webhook pings, capacity checks, and any write that should happen after a document is successfully saved.
Example: Asynchronous afterChange Side Effect
import { defineCollection } from '@dyrected/core'
import { sendEmail } from '../utils/email'
export const Registrations = defineCollection({
slug: 'registrations',
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
// Trigger email asynchronously
await sendEmail({
to: doc.email,
subject: 'Registration Confirmed',
body: `Hi ${doc.name}, thank you for registering!`,
})
}
}
]
},
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'email', label: 'Email', type: 'email', required: true },
]
})Global hooks
Globals support the same hooks as collections, except beforeDelete and afterDelete (globals cannot be deleted):
import { defineGlobal } from '@dyrected/core'
export const SiteSettings = defineGlobal({
slug: 'site-settings',
hooks: {
beforeRead: [...],
afterRead: [...],
beforeChange: [...],
afterChange: [...],
},
fields: [...],
})Field hooks
Field hooks run at the individual field level and are ideal for value transformations. They run recursively inside array, object, and blocks fields — every nested item is processed automatically.
{
name: 'email',
type: 'email',
hooks: {
beforeChange: [({ value }) => value?.toLowerCase().trim()],
afterRead: [({ value, user }) => user?.role === 'admin' ? value : '****'],
}
}| Hook | When it runs | Arguments | Return value |
|---|---|---|---|
beforeChange | Before saving this field's value | { value, originalDoc, data, user } | The new value to store |
afterRead | After reading this field's value | { value, doc, user } | The transformed value to return |
originalDoc— the full document as it existed before the update (undefinedon create).data— the full incoming payload being written.doc— the full document being returned (on read).
Admin UI Hooks
Admin UI hooks run in the browser inside the Admin form. They fire every time any field value changes, letting you make the form reactive without any network round-trips.
They are defined under field.admin.hooks:
{
name: 'slug',
type: 'text',
admin: {
hooks: {
onChange: ({ siblingData }) =>
(siblingData.title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
},
},
}onChange
Runs whenever any sibling field changes. Use it to derive or compute a field's value from other fields in real time.
Arguments: { value, siblingData, data, setValue }
value— the current value of this field.siblingData— current values of all fields at the same level.data— current values of the entire form (same assiblingDatafor top-level fields).setValue(v)— imperative setter; useful for async side-effects where you can't just return.
Return: the new value for this field, or undefined to leave it unchanged.
// Auto-generate a slug from the title
onChange: ({ siblingData }) =>
(siblingData.title ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
// Calculate a total price from quantity and unit price
onChange: ({ siblingData }) => {
const qty = Number(siblingData.quantity) || 0
const price = Number(siblingData.unitPrice) || 0
return qty * price
}options
Available on select, multiSelect, and radio fields only.
Runs whenever any sibling field changes. Use it to compute the available choices for this field based on other field values (cascading / dependent dropdowns).
Arguments: { siblingData, data }
Return: an array of string | { label: string; value: any } — the new option list.
When the returned list changes, the field's current value is automatically cleared if it is no longer a valid choice.
// Country → State cascading dropdown
{
name: 'state',
type: 'select',
options: [],
admin: {
hooks: {
options: ({ siblingData }) => {
if (siblingData.country === 'us') {
return [
{ label: 'California', value: 'CA' },
{ label: 'New York', value: 'NY' },
]
}
if (siblingData.country === 'ca') {
return [
{ label: 'Ontario', value: 'ON' },
{ label: 'British Columbia', value: 'BC' },
]
}
return []
},
},
},
}onChange vs options: onChange sets a field's value. options sets the available choices for a select/radio field. Never return an options list from onChange, and never return a plain value from options.
Hook chaining
When you define multiple hooks in an array they run sequentially. Each hook receives the output of the previous one.
beforeChange: [
normaliseWhitespace, // runs first
validateLength, // receives output of normaliseWhitespace
generateExcerpt, // receives output of validateLength
]For collection beforeChange, each hook receives the full data object and can return a modified version. For field beforeChange, each hook receives the current field value.
Aborting an operation
Throw inside any hook to abort the operation. The database write is cancelled and the API returns a 500 error with the thrown message.
beforeDelete: [
async ({ id }) => {
const refs = await db.find({ collection: 'pages', where: { featuredPost: { equals: id } } })
if (refs.total > 0) {
throw new Error(`Cannot delete: referenced by ${refs.total} page(s).`)
}
}
]Use this pattern for beforeChange validation too — throw a descriptive error and the data will never reach the database.
Choosing between field hooks and collection hooks
Both can run logic before or after a write, but they operate at different scopes and have different trade-offs.
| Field hook | Collection hook | |
|---|---|---|
| Scope | A single field's value | The full data object |
| Input | { value, originalDoc, data, user } | { data, doc, req, user, operation } |
| Output | The new value for this field | A modified version of the whole data object |
Runs inside array/object/blocks | Yes — automatically for every nested item | No — only at the top-level document |
| Can read sibling fields | Yes — via data | Yes — the whole payload is available |
| Can write back multiple fields | No — only its own value | Yes |
| Typical use | Normalise, transform, mask one value | Derive cross-field values, validate multiple fields together, generate slugs |
Use a field hook when
- The logic only needs the field's own value and does not depend on other fields
- You want the transformation to apply recursively inside
array,object, orblocks(field hooks automatically run on every nested item — collection hooks do not) - The hook is a simple, stateless transformation: trim whitespace, lowercase an email, hash a password, mask a sensitive value on read
// Good fit for a field hook — only needs its own value
{
name: 'email',
type: 'email',
hooks: {
beforeChange: [({ value }) => value?.toLowerCase().trim()],
},
}Use a collection hook when
- The logic reads or writes more than one field (e.g. generate
slugfromtitle, computetotalfromquantity × price) - You need to validate multiple fields together and throw a single clear error
- The hook needs
operationto behave differently on create vs update - You need
previousDocto detect what changed (only available inafterChange)
// Good fit for a collection hook — reads title to write slug
hooks: {
beforeChange: [
({ data, operation }) => {
if (operation === 'create' || data.title !== undefined) {
return { ...data, slug: slugify(data.title, { lower: true, strict: true }) }
}
return data
},
],
}When in doubt
Start with a field hook. Promote to a collection hook only if you find yourself reaching for data to read a sibling field or needing to return a modified version of the whole object.