adrianmaj.com

Payload,  Next.js

PayloadCMS from Scratch #4 - Practical use of hooks

Author

Adrian Maj

Date published

PayloadCMS from Scratch #4 - Practical use of hooks

When performing operations such as saving, creating, or deleting records in collections, we often need to execute additional operations - save information to logs, revalidate cache, or integrate with external services. For these types of operations, called side-effects, we use hooks, which are an essential part of every larger application.

Payload allows creating hooks at four different levels - from the application level (Root Hook) down to individual fields in a collection (Field Hook). Each of these levels receives different data and has slightly different implementation, so let's take a detailed look at each one.

Root Hooks

As of writing this post, there's only one hook of this type available - afterError. As the name suggests, it's triggered when an error occurs in the application. Root Hooks aren't tied to a specific collection but work across the entire application. The afterError hook can be used, for example, to report errors to external monitoring services.

To add a Root Hook to your project, you need to add a hooks property to the configuration object in the payload.config.ts file, which is an object containing the names of available hooks. Each hook is an array of functions executed when it's called. The implementation looks like this:

1import { buildConfig } from 'payload'
2
3export default buildConfig({
4 // Your payload config
5 hooks: {
6 afterError: [
7 async ({ error }) => {},
8 ],
9 },
10})

Hooks can be both synchronous and asynchronous and receive different data depending on their type. You can read more about the afterError hook and Root Hooks in the documentation.

Collection Hooks

Let's move on to the most commonly used hooks - for collections. The operating principle is similar to Root Hooks, except they work within a single collection and are defined there as well. There are significantly more hooks operating at the collection level, so I won't cover each one individually - you'll find their complete documentation here.

Collection hooks in most cases receive the following parameters:

  • collection - the collection on which the hook operates
  • doc, originalDoc or previousDoc - the document on which they're called (for hooks responsible for document changes, variants before and after the operation are available)
  • req - Web Request object, through which you can gain access to the Local API, among other things
  • context - additional context allowing data to be passed between different hooks (useful for avoiding infinite hook loops, for example)

Let's see what an example hook looks like:

1import type { CollectionConfig } from "payload";
2
3export const Users: CollectionConfig = {
4 slug: "users",
5 hooks: {
6 afterChange: [
7 async ({ req, collection, doc }) => {
8 const payload = req.payload;
9 const tenant = await payload.findByID({
10 collection: "tenants",
11 id: typeof doc.tenant === "string" ? doc.tenant : doc.tenant.id
12 })
13 // bring your own logic here
14 },
15 ],
16 },
17}

In this example, the afterChange hook uses the req object to gain access to the Local API, then retrieves the tenant assigned to the user. The hook executes after every change to any field in the collection - the logic inside the hook is fully configurable and can be adapted to specific project requirements.

💡 Tip: Relational fields in the returned document (both in doc and in Local API data) are always typed as the target collection object OR string. This is because types aren't generated based on permissions - if we lack access to the referenced document, we'll only receive its ID. Therefore, you should always verify the type, similar to the example with the id field above.

Collection Hooks are fully typed, utilizing generic types - this means that when creating a hook, we must provide the slug or collection type (depending on the hook) for which we're defining it. Here are two examples of different typing approaches:

  • Typing using collection slug
1import { type CollectionAfterOperationHook } from "payload";
2
3export const afterOperationHook: CollectionAfterOperationHook<"collection-slug"> = async ({
4 operation,
5 req,
6 result,
7}) => {
8 // some custom logic
9 return result;
10};
11
  • Typing using collection type
1import { type CollectionAfterChangeHook } from "payload";
2import { type Reservation } from "@/payload-types";
3
4export const afterChangeHook: CollectionAfterChangeHook<Reservation> = async ({
5 doc,
6 previousDoc,
7 req,
8}) => {
9 // some custom logic
10 return doc;
11}

A hook prepared this way provides full type-safety and allows you to seamlessly add your own logic to existing operations.

Global Hooks

The configuration and operating principle of Global Hooks is practically identical to Collection Hooks, but we have significantly fewer hooks here - this results from the smaller number of operations possible to perform on global data. The only difference in parameters is replacing collection with the global parameter, which returns the global collection handling the given hook.

Since Global Hooks function almost identically to Collection Hooks, there's no point in discussing them in detail - if you need specific information about them, check the Payload documentation.

Field Hooks

Another important and very frequently used type of hooks, which allows you to react to changes in a specific field of a collection/global. Field Hooks are configured at the individual field level and receive slightly different data than Collection Hooks, which you'll find described in the documentation.

It's worth mentioning that Field Hooks typing differs from other hooks - in this case, we have one main type available - FieldHook, which is also generic but takes as many as 3 arguments:

  1. Document Type - the type of collection in which this field is located.
  2. Field Type - if it's a text field, this will be string, etc.
  3. Sibling Data Type - the type of fields occurring at the same level as the field on which the hook is called (e.g., in the same group).

Here's what an example Field Hook might look like:

1import type { FieldHook } from 'payload'
2import { type MyCollection } from "@/payload-types";
3
4type SiblingDataType = {
5 price: number;
6 description: string;
7}
8
9const titleFieldHook: FieldHook<MyCollection, string, SiblingDataType> = (args) => {
10 const {
11 value, // Title - Typed as `string` as shown above
12 data, // Typed as a Partial of MyCollection
13 siblingData, // Typed as a Partial of SiblingDataType
14 originalDoc, // Typed as MyCollection
15 operation,
16 req,
17 } = args
18
19 // Do something here...
20
21 return value;
22}

In this example, the hook handles the title field and thanks to generic types, we get full type-safety for all parameters. The hook must return a value consistent with the field type - in this case string, undefined, or null.

Summary

Hooks constitute a key mechanism for extending Payload's default functionality, enabling you to add your own logic to standard operations in a simple and pleasant way. In this article, you learned about four types of hooks - from Root Hooks operating within the entire application to Field Hooks reacting to changes in individual fields. Thanks to full typing and generic types, you can safely implement side-effects tailored to your project's specific requirements.

In the next article, we'll cover equally useful Custom Fields, which allow you to create your own field types that go beyond Payload's standard capabilities. You'll also learn practical applications of hooks combined with Custom Fields and ways to optimize application performance.

If you want to continue learning and dive deeper into PayloadCMS, I encourage you to follow my blog. Additionally, on my social media profiles, I'll be sharing information about new posts, updates, and projects related to Payload.