Dyrecteddyrected
Core Concepts

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: [...],
})
HookWhen it runsArgumentsReturn value
beforeReadBefore the database query{ req, query, user }Modified where query, or void
afterReadAfter fetching, before the response{ doc, req, user }Modified doc
beforeChangeBefore insert or update{ data, doc, req, user, operation }Modified data object
afterChangeAfter the write is committed{ doc, previousDoc, req, user, operation }void (return value ignored)
beforeDeleteBefore the record is removed{ id, doc, req, user }void — throw to abort
afterDeleteAfter the record is removed{ id, doc, req, user }void
beforeTransitionBefore a workflow state transition{ id, doc, req, user, transition, from, to }void — throw to abort
afterTransitionAfter a workflow state transition{ id, doc, req, user, transition, from, to }void

Notes on specific arguments:

  • operation — present on beforeChange and afterChange: either 'create' or 'update'.
  • doc on beforeChange — the existing document when updating; undefined on create.
  • previousDoc on afterChange — snapshot of the document before the write. Use it to detect what changed.
  • query on beforeRead — the current where filter; 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).

HookAsyncDB readsDB writesSide 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 : '****'],
  }
}
HookWhen it runsArgumentsReturn value
beforeChangeBefore saving this field's value{ value, originalDoc, data, user }The new value to store
afterReadAfter reading this field's value{ value, doc, user }The transformed value to return
  • originalDoc — the full document as it existed before the update (undefined on 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 as siblingData for 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 hookCollection hook
ScopeA single field's valueThe full data object
Input{ value, originalDoc, data, user }{ data, doc, req, user, operation }
OutputThe new value for this fieldA modified version of the whole data object
Runs inside array/object/blocksYes — automatically for every nested itemNo — only at the top-level document
Can read sibling fieldsYes — via dataYes — the whole payload is available
Can write back multiple fieldsNo — only its own valueYes
Typical useNormalise, transform, mask one valueDerive 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, or blocks (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 slug from title, compute total from quantity × price)
  • You need to validate multiple fields together and throw a single clear error
  • The hook needs operation to behave differently on create vs update
  • You need previousDoc to detect what changed (only available in afterChange)
// 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.

On this page