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:
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.
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, andAfterRead. - Observers react to the value. They return only an error:
AfterChange,BeforeDelete,AfterDelete,AfterOperation, andAfterCommit.
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.
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.
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.
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:
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:
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.
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.