Browse all documentation

Field hooks

Clean up input, change a typed value, format a response, or react to a saved change with hooks attached to one field.

Field hooks run for one field’s value. Use them to trim a title, uppercase a product code, or format what the API returns. A field keeps its hooks wherever you use it, including inside groups, arrays, and blocks. When a change involves several fields, use a collection hook instead.

A field has two places for hooks. .Hooks(...) holds the hooks that work with the stored value, and .AfterRead(...) holds the hooks that change the value Ridu returns:

go
field.Text("title").
  // Hooks that work with the value Ridu stores.
  Hooks(field.Hooks[string]{
    BeforeValidate: []field.RawTransform{cleanInput},
    BeforeChange:   []field.Transform[string]{formatValue},
    AfterCommit:    []field.Observer[string]{notifyChange},
  }).
  // Hooks that change the value Ridu returns.
  AfterRead(formatForDisplay)

T in field.Hooks[T] is the Go type of the stored value: string for a Text field. AfterRead is a method rather than a Hooks entry, because some fields return a different type from the one they store. Why AfterRead is registered separately explains the difference.

To supply a value only when a field is left empty, use a default value instead of a hook.

Hook arguments

Every field hook receives two arguments: ctx, which describes the operation, and value, this field’s current value.

go
func formatValue(
  ctx operation.Context,
  value operation.Value[string],
) (operation.Change[string], error) {
  text, present := value.Get()
  // ...
}
Argument Description
value This field’s value. value.Get() returns the value and whether one is present.
ctx.Operation What is happening, such as operation.Create, operation.Update, or operation.Read.
ctx.ID The document’s ID. On a create it is empty until the document is saved; on a duplicate it is the copy’s ID once saved.
ctx.Actor.ID The signed-in user’s ID, or empty for an anonymous request. ctx.Actor.Data holds the user’s fields.
ctx.Siblings The fields next to this one: the same group, the same array row, or the top level. Includes this field.
ctx.Root The document’s top-level fields.
ctx.Prior The saved values of this field’s group or row before the operation. Empty on create.
ctx.Locale The content locale, or empty without localization.
ctx.Local Reads another document: ctx.Local.FindByID(ctx.Context, "users", id). It cannot write.
ctx.Context Cancellation and deadline. Pass it to ctx.Local and network calls.

Read another field with ctx.Siblings.String("title"), or ctx.Siblings.Get("price").NumberValue() for other types. These views are read-only: a hook changes only its own field, through its return value. Operations and callbacks explains operation.Value and operation.Context in more detail, and Using other field values covers nested rows and translations.

Return values

Field hooks come in two kinds:

  • Transforms can change the value. They return an operation.Change[T] and an error: BeforeValidate, BeforeDuplicate, BeforeChange, BeforeOperation, and AfterRead.
  • Observers react to the value. They return only an error: AfterChange, BeforeDelete, AfterDelete, AfterOperation, and AfterCommit.

A transform returns one of these:

Return value Effect
operation.Keep[string]() Keep the current value
operation.Set("ABC") Set it to "ABC"
operation.Clear[string]() Clear it; Required() and other validation still apply
A non-nil error Stop the operation; a change returned with the error is not applied

An error from any hook that runs before the transaction commits stops the operation and rolls back its writes. Return ridu.Reject("…") to tell the editor why: the admin shows the message, and an operation.Issue passed to it marks a field, with its Target starting at this field. Any other error is reported as an internal error. An error from AfterCommit cannot undo the save.

BeforeValidate

Runs before Ridu checks the value when a document is created, duplicated, updated, published, or unpublished. The input may be missing or have the wrong type, so the hook receives the raw input as a store.Value. Use it to clean up input before validation.

content/posts.go
package content

import (
  "strings"

  "github.com/riducms/ridu"
  "github.com/riducms/ridu/field"
  "github.com/riducms/ridu/operation"
  "github.com/riducms/ridu/store"
)

func trimText(
  _ operation.Context,
  value operation.Value[store.Value],
) (operation.Change[store.Value], error) {
  raw, present := value.Get()
  if !present {
    // An update may omit this field; leave it unchanged.
    return operation.Keep[store.Value](), nil
  }
  text, valid := raw.StringValue()
  if !valid {
    // Let Ridu validate values that are not strings.
    return operation.Keep[store.Value](), nil
  }
  // Set replaces this field's value with the trimmed text.
  return operation.Set(store.String(strings.TrimSpace(text))), nil
}

var Posts = ridu.Collection{
  Slug: "posts",
  Fields: field.Fields{
    field.Text("title").Required().Hooks(field.Hooks[string]{
      BeforeValidate: []field.RawTransform{trimText},
    }),
  },
}

Submitting " Hello, Ridu " stores "Hello, Ridu". A value that is not a string is left for Ridu’s own validation to reject, and an update that does not send the title leaves it alone.

BeforeChange

Runs after the built-in checks and before the value is saved, for create, duplicate, update, publish, and unpublish. The value has the field’s Go type: operation.Value[string] for a Text field. Use it to change a valid value before saving.

content/sku.go
package content

import (
  "strings"

  "github.com/riducms/ridu/field"
  "github.com/riducms/ridu/operation"
)

func uppercaseSKU(
  _ operation.Context,
  value operation.Value[string],
) (operation.Change[string], error) {
  sku, present := value.Get()
  if !present {
    return operation.Keep[string](), nil
  }
  // Set changes this field; Ridu validates the result again.
  return operation.Set(strings.ToUpper(sku)), nil
}

var SKU = field.Text("sku").Required().Hooks(field.Hooks[string]{
  BeforeChange: []field.Transform[string]{uppercaseSKU},
})

Add SKU to a collection’s Fields. Saving ab-12 stores AB-12. On an update, the hook receives the saved value when the caller did not send this field. Your custom .Validate(...) rules run after this hook.

AfterRead

Runs for each document Ridu returns, including the responses to creates and updates. It changes the response without changing what is stored. Register it with the field’s .AfterRead(...) method.

content/display_code.go
package content

import (
  "strings"

  "github.com/riducms/ridu/field"
  "github.com/riducms/ridu/operation"
)

func formatCode(
  _ operation.Context,
  value operation.Value[string],
) (operation.Change[string], error) {
  code, present := value.Get()
  if !present {
    return operation.Keep[string](), nil
  }
  // In an AfterRead hook, Set changes the response, not storage.
  return operation.Set(strings.ToUpper(code)), nil
}

var DisplayCode = field.Text("displayCode").AfterRead(formatCode)

Save ab-12, and the response contains AB-12 while the database still stores ab-12. Filters and sorting use the stored value, and the admin shows the returned one. To store the uppercase value everywhere, use BeforeChange instead.

Ridu removes fields the caller cannot read after this hook runs. A hook may see values that the caller is not allowed to read, so do not copy them into a field the caller can read.

Relationship and upload fields

A relationship stores a document ID but returns an operation.ReferenceOutput, which also holds the related document when the request asks for it. That is why its two kinds of hooks receive different types:

content/reviewer.go
package content

import (
  "github.com/riducms/ridu/field"
  "github.com/riducms/ridu/operation"
  "github.com/riducms/ridu/store"
)

// Write hooks receive the stored value: the reviewer's document ID.
func clearSelfReview(
  ctx operation.Context,
  value operation.Value[operation.ID],
) (operation.Change[operation.ID], error) {
  id, present := value.Get()
  if !present || ctx.Actor.ID == "" || id != ctx.Actor.ID {
    return operation.Keep[operation.ID](), nil
  }
  // Authors cannot review their own work; save it unreviewed.
  return operation.Clear[operation.ID](), nil
}

// Read hooks receive the response value, which may be the populated reviewer.
func showReviewerName(
  _ operation.Context,
  value operation.Value[operation.ReferenceOutput],
) (operation.Change[operation.ReferenceOutput], error) {
  reference, present := value.Get()
  if !present {
    return operation.Keep[operation.ReferenceOutput](), nil
  }
  reviewer, populated := reference.Document()
  if !populated {
    // An unpopulated response holds only the ID; leave it alone.
    return operation.Keep[operation.ReferenceOutput](), nil
  }
  // Return the reference with only the reviewer's name.
  return operation.Set(operation.Populated(store.Document{
    ID:     reviewer.ID,
    Values: store.Values{"name": reviewer.Values["name"]},
  })), nil
}

var Reviewer = field.Relationship("reviewer", "users").
  Hooks(field.Hooks[operation.ID]{
    BeforeChange: []field.Transform[operation.ID]{clearSelfReview},
  }).
  AfterRead(showReviewerName)

An author who picks themself as the reviewer saves the document without a reviewer. A read that populates reviewer returns only the reviewer’s ID and name. A read without population returns the ID unchanged.

BeforeDuplicate

Runs when a document is duplicated, before BeforeValidate. It receives the copied value as a raw store.Value. Use it for values that must not be copied as they are.

Ridu already gives a copied field.Slug the next free value, such as about-us-copy. Other unique fields need a hook, or the copy fails with a unique error on that field. This field links a product to Stripe, and a copy must not claim the same Stripe product:

content/external_id.go
package content

import (
  "github.com/riducms/ridu/field"
  "github.com/riducms/ridu/operation"
  "github.com/riducms/ridu/store"
)

func unlinkCopy(
  _ operation.Context,
  _ operation.Value[store.Value],
) (operation.Change[store.Value], error) {
  // The copy is a new product that Stripe does not know about yet.
  return operation.Clear[store.Value](), nil
}

var StripeProductID = field.Text("stripeProductId").Unique().Hooks(
  field.Hooks[string]{
    BeforeDuplicate: []field.RawTransform{unlinkCopy},
  },
)

Duplicating a product saves the copy without a stripeProductId, ready to be linked to a new Stripe product.

AfterCommit

Runs after the transaction commits, for every change, including deletes and restores, but never after a read. The value is already saved, so an error here cannot undo it. Use it for side effects that should only happen after a successful save, such as logging a change or notifying another service.

content/status.go
package content

import (
  "log"

  "github.com/riducms/ridu/field"
  "github.com/riducms/ridu/operation"
)

func logStatusChange(
  ctx operation.Context,
  value operation.Value[string],
) error {
  status, _ := value.Get()
  // Prior holds the values saved before this operation.
  previous, _ := ctx.Prior.String("status")
  if status == previous {
    return nil
  }
  // The change is committed, so this never logs a rolled-back save.
  log.Printf("status %q -> %q by user %q",
    previous, status, ctx.Actor.ID)
  return nil
}

var Status = field.Select("status", "draft", "review", "approved").
  Hooks(field.Hooks[string]{
    AfterCommit: []field.Observer[string]{logStatusChange},
  })

Changing a page’s status from draft to review logs one line. Saving the page without changing its status, or reading it, logs nothing. Field AfterCommit hooks run before the collection’s AfterCommit hooks.

Other field hooks

These hooks follow the same patterns as the examples above:

Hook Kind When it runs
BeforeOperation Transform Just before the database call, for every operation, including reads and deletes. On a save, it runs after BeforeChange.
AfterChange Observer After a create, duplicate, update, publish, or unpublish is saved, before commit. An error rolls back the save.
BeforeDelete Observer Before a document moves to the trash or is deleted permanently. It receives the saved value. An error stops the delete.
AfterDelete Observer After the delete, before commit. An error restores the document.
AfterOperation Observer After the database call for every operation, including reads and deletes, before commit.

BeforeOperation and AfterOperation also run for reads, where the value can be empty. Check ctx.Operation when they should act only on saves. The collection hook order shows where each field hook runs.

Types

The type of value depends on the field and on whether the hook works with the stored value or the returned one:

Field Stored value in field.Hooks[T] Returned value in .AfterRead(...)
Text, Textarea, Email, Select, Date string string
Number float64 float64
Checkbox bool bool
TextList, NumberList []string, []float64 []string, []float64
Relationship, Upload operation.ID operation.ReferenceOutput
Relationships, Uploads []operation.ID []operation.ReferenceOutput
Group, Array, Blocks, JSON, Point, and polymorphic relationships store.Value store.Value
Join No stored value store.Value
Virtual No stored value The resolver’s type, such as string

BeforeValidate and BeforeDuplicate always receive a raw store.Value, because they run before Ridu has checked the type. A list field’s hook receives the whole list at once, not one item at a time. The function types are field.RawTransform, field.Transform, field.OutputTransform, and field.Observer.

Why AfterRead is registered separately

Writing field.Hooks[string]{AfterRead: ...} fails to compile with unknown field AfterRead in struct literal. Every list in field.Hooks[T] works with the stored type T, but a field’s returned value can have a different type: a relationship stores an operation.ID and returns an operation.ReferenceOutput. Join and Virtual fields store nothing at all, so they have .AfterRead(...) but no .Hooks(...).

For most fields the two types are the same, so one function can be used as a BeforeChange transform or with .AfterRead(...). Where you register it decides when it runs.

Reuse and extend a field

Define a field with its hooks once, then use it in any collection, group, array, or block. Each hook runs for the value at that location, so it never needs an array index.

To add hooks to an existing field, use these builders. They return a new field and never modify the one you started from:

Stored-value hooks (field.Hooks[T]) Returned-value hooks
.Hooks(...) replaces all of them .ReplaceAfterRead(...) replaces them
.AppendHooks(...) adds after existing ones .AfterRead(...) adds after them
.PrependHooks(...) adds before existing ones

Hooks in the same list run in the order they were added. .ReplaceAfterRead() with no arguments removes the returned-value hooks.

Compared with collection hooks

Collection hooks receive the whole document through ctx.Data and return only an error. Field hooks receive one value and return a change for it. Most phases exist in both, with these differences:

Phase Collection or global Field
BeforeRead CollectionHooks.BeforeRead Not available
AfterRead CollectionHooks.AfterRead The field’s .AfterRead(...) method
AfterError CollectionHooks.AfterError Not available; use the resource hook
Every other phase A CollectionHooks list A field.Hooks[T] list

In a shared phase, collection hooks run before field hooks, except AfterCommit, where field hooks run first.

Live checks run before these hooks

A .LiveValidate(...) check runs while an author edits, on unsaved input, before any of these hooks. A title that a BeforeValidate hook would trim is still untrimmed when its live check reads it. See Live server validation.

For large arrays and blocks, Hook and value performance explains how to keep hooks that run once per row fast.