Skip to Page NavigationSkip to Page NavigationSkip to Content

Hooks

Keystone provides a powerful CRUD GraphQL API which lets you perform basic operations on your data. As your system evolves you'll find that you need to include business logic alongside these operations.

In this guide we'll show you how to use hooks to enhance the core operations in different ways. For full details of the function signatures, please check out the Hooks API.

What is a hook?

A hook is a function you define as part of your schema configuration which is executed when a GraphQL operation is performed. Let's look at a basic example to log a message to the console whenever a new user is created.

import { config, list } from '@keystone-6/core';
import { text } from '@keystone-6/core/fields';
export default config({
lists: {
User: list({
fields: {
name: text(),
email: text(),
},
hooks: {
afterOperation: ({ operation, item }) => {
if (operation === 'create') {
console.log(`New user created. Name: ${item.name}, Email: ${item.email}`);
}
}
},
}),
},
});

This function will be triggered whenever we execute a create, update, or delete mutation in our GraphQL API. It will be executed once for each item being operated on. Because we only want to log when a user is created, we check the value of the operation argument. We then use the item argument to get the value of the newly created user.

Now that we've got a sense of what a hook is, let's look at how we can use hooks to solve some common problems you'll hit when creating your system.

Modifying incoming data

When a create or update operation is called, you might want to apply some pre-processing to the data before saving it to your database. For example, if you have a blog post, you might want to ensure that the title field always starts with an upper-case letter. The resolveInput hook lets us take the data which has been provided to the GraphQL mutation and modify it before it is saved.

Let's write a hook which takes the data for a blog post and converts the first letter to upper-case.

import { config, list } from '@keystone-6/core';
import { text } from '@keystone-6/core/fields';
export default config({
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: text({ validation: { isRequired: true } }),
},
hooks: {
resolveInput: ({ resolvedData }) => {
const { title } = resolvedData;
if (title) {
return {
...resolvedData,
// Ensure the first letter of the title is capitalised
title: title[0].toUpperCase() + title.slice(1)
}
}
// We always return resolvedData from the resolveInput hook
return resolvedData;
}
},
}),
},
});

We must always return the modified resolvedData value from our hook, even if we didn't end up changing it.

The resolveInput hook is called whenever we update or create an item. The value of resolvedData will contain the input provided to the mutation itself, with any field type input resolvers applied. For example, a password field will convert the provided text input into an encrypted hash value. If you just want to see what the original input was, before the field type resolvers were applied, you can use the inputData argument.

If you're performing an update operation, you might also want to access the current value of the item stored in the database. This is available as the item argument.

Finally, all hooks are provided with a context argument, which gives you access to the full context API.

Validating inputs

Before writing the resolved data to the database you will often want to check that it conforms to certain rules, depending on your application's needs. For example, you might want to ensure that blog posts don't have a blank title. An empty string, "", is a perfectly valid String value to pass into GraphQL. Let's use a validation hook to ensure that this value doesn't make it into our database.

import { config, list } from '@keystone-6/core';
import { text } from '@keystone-6/core/fields';
export default config({
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: text({ validation: { isRequired: true } }),
},
hooks: {
validateInput: ({ resolvedData, addValidationError }) => {
const { title } = resolvedData;
if (title === '') {
// We call addValidationError to indicate an invalid value.
addValidationError('The title of a blog post cannot be the empty string');
}
}
},
}),
},
});

The validateInput hook is passed the resolvedData value after all defaults and resolveInput hooks have been completed. This is the value which will be written into the database if no validation errors are found.

We can check the values on this object and if there's a problem we call the function addValidationError with an error message. There might be multiple problems with the input, so you can call addValidationError multiple times to capture of all the different problems.

Keystone will abort the operation and convert these error messages into GraphQL errors which will be returned to the caller.

The validateInput hook also receives the operation, inputData, item and context arguments if you want to perform more advanced checks.

Don't confuse data validation with access control. If you want to check whether a user is allowed to do something, you should set up access control rules.

Triggering side-effects

When data is changed in our system we might want to trigger some external side-effect. For example, we might want to send a welcome email to a user when they first create their account. We can use the beforeOperation and afterOperation hooks to do this. Let's send an email after a user is created.

import { config, list } from '@keystone-6/core';
import { text } from '@keystone-6/core/fields';
// Keystone leaves it up to you to decide how best to implement email in your system
import { sendWelcomeEmail } from './lib/welcomeEmail';
export default config({
lists: {
User: list({
fields: {
name: text(),
email: text(),
},
hooks: {
afterOperation: ({ operation, item }) => {
if (operation === 'create') {
sendWelcomeEmail(item.name, item.email);
}
}
},
}),
},
});

The beforeOperation and afterOperation hooks are very similar, but serve slightly different purposes. The beforeOperation hook receives an item argument, which contains the currently stored in the database before the operation. In the afterOperation hook, item represents the newly updated data in the database, and the original data from before the update is provided as originalItem. In a create operation, there won't be a pre-existing item, and for delete operations, the value of item in the afterOperation hook will be null.

If the beforeOperation hook throws an exception then the operation will return an error, and the data will not be saved to the database. If the afterOperation hook throws an exception then the data will remain in the database. As such, afterOperation hooks should be used where a failure to execute isn't a critical problem.

List hooks vs field hooks

All of the examples above have involved hooks associated with a particular list. Keystone also supports setting of hooks associated with a particular field. All the same hooks are available, and they receive all the same arguments, along with an extra fieldKey argument.

Field hooks can be useful if you want to have field specific rules. For example, you might have an email validation function which want to use in your system. You could always write this as a list hook, but it will make your code more clear if you write this as a field hook.

import { config, list } from '@keystone-6/core';
import { text } from '@keystone-6/core/fields';
export default config({
lists: {
User: list({
fields: {
name: text(),
email: text({
validation: { isRequired: true },
hooks: {
validateInput: ({ addValidationError, resolvedData, fieldKey }) => {
const email = resolvedData[fieldKey];
if (email !== undefined && email !== null && !email.includes('@')) {
addValidationError(`The email address ${email} provided for the field ${fieldKey} must contain an '@' character`);
}
},
},
}),
},
}),
},
});

See the Hooks API for the details of all the arguments available for all the different hook functions.