The Listener Pattern in PayloadCMS
Using React principles to create useful behaviour for your PayloadCMS Admin Panel
PayloadCMS is a Next.js (React) app. This means the typical React idioms apply, and you can use hooks provided by Payload to react to changes in the Admin UI, like in forms.
Listeners (as I call them) are React custom components that do not output any UI, and use React’s re-rendering to listen for events in the form and act on them. This can be useful if you need to auto-fill values (including relationships) or if you need the UI to update in response to input value changes before the validation / submission hooks are run (e.g. to display useful information).
Note: I recommend looking at whether lifecycle hooks can satisfy your needs first, before committing to using Listeners. Listeners add a layer of redirection to your business logic, and sometimes it might be easier to just tell your users to leave fields empty, then let them be computed by your lifecycle hooks on save.
Want to skip to the code? Here’s the repository: https://github.com/tyteen4a03/payload-listener-pattern-example
Example #1: It’s Just React
To illustrate how Listeners are just normal React components, here’s a simple listener example. We’ll create a listener that adds two numbers together and dump the result in another field.
To start us off, we have a Numbers
collection with a few fields:
import { CollectionConfig } from 'payload'
const Numbers: CollectionConfig = {
slug: 'numbers',
fields: [
{
name: 'numberOne',
type: 'number',
required: true,
},
{
name: 'numberTwo',
type: 'number',
required: true,
},
{
name: 'numberThree',
type: 'number',
required: true,
},
// Listeners can be placed anywhere you like, but I recommend either at the start or the end of the fields list for your sanity
{
name: 'numberAdderListener',
type: 'ui',
admin: {
components: {
Field: '@/components/NumberAdderListenerField/NumberAdderListenerField',
},
},
},
{
name: 'numberFour',
type: 'number',
},
],
}
export default Numbers
The Listener’s code is as below:
'use client'
import { useField, useForm, useFormFields } from '@payloadcms/ui'
import { useEffect } from 'react'
const NumberAdderListenerField = () => {
const numberOne = useFormFields(([fields]) => fields.numberOne)
const numberTwo = useFormFields(([fields]) => fields.numberTwo)
const { dispatchFields } = useForm()
const { formInitializing } = useField({
path: '',
})
useEffect(() => {
if (formInitializing) {
// Don't run if form is still initializing
return
}
console.log('my listener is being run')
const newValue = parseFloat(numberOne.value as string) + parseFloat(numberTwo.value as string)
if (Number.isNaN(newValue)) {
return
}
dispatchFields({
type: 'UPDATE',
path: 'numberThree',
value: newValue,
})
}, [numberOne.value, numberTwo.value, dispatchFields, formInitializing])
return <></>
}
export default NumberAdderListenerField
Let’s highlight some interesting chunks of the listener. We start with this line:
'use client'
All Listeners are Client Components, as they listen for the form state’s changes which only happen in the client. (The next example will touch on how to use Server Components in some situations as well).
Listeners utilises React Hooks provided by the @payloadcms/ui
package, as well from Stock React. I am highlighting this here because the templates currently, for some reason, do not have @payloadcms/ui
listed as a dependency, which doesn’t make sense to me. Here’s a GitHub discussion on changing this, but if the package is not in your package.json, make sure to add it first.
import { useForm, useFormFields } from '@payloadcms/ui'
import { useEffect } from 'react'
This code utilises the useFormFields hook, provided by Payload, to select the numberOne
and numberTwo
fields; the syntax is fairly similar to Redux’s selectors. The dispatchFields
function, which we’ll use to update the form state, comes from the useForm hook1. You can see a list of actions accepted by dispatchFields
here.
The numberOne and numberTwo variables now hold a FieldState object, which contains the value of the field as well as other properties.
const numberOne = useFormFields(([fields]) => fields.numberOne)
const numberTwo = useFormFields(([fields]) => fields.numberTwo)
const { dispatchFields } = useForm()
Here we check for whether the form has finished initialising - we don’t want to run our listeners until the entire form is ready. We use useField
on the root form instead of useForm
’s initializing property because of a Payload bug - bug report here.
const { formInitializing } = useField({
path: '',
})
This is a standard useEffect
hook function. After the form is initialised, whenever the value of numberOne
or numberTwo
changes, the effect is run, and dispatchFields
is called with an update to the numberThree
field, containing the value of numberOne and numberTwo added together.
useEffect(() => {
if (formInitializing) {
// Don't run if form is still initializing
return
}
console.log('my listener is being run')
const newValue = parseFloat(numberOne.value as string) + parseFloat(numberTwo.value as string);
if (Number.isNaN(newValue)) {
return
}
dispatchFields({
type: 'UPDATE',
path: 'numberThree',
value: newValue,
})
}, [numberOne.value, numberTwo.value, dispatchFields])
NOTE! Make sure you specify only the value
property in the dependencies list, or else your effect will trigger whenever you edit any field. To see this behaviour, change the dependency list to:
[numberOne, numberTwo, dispatchFields]
and edit the numberFour
field. You will see the “my listener is being run”, even when we did not select numberFour
in our code.
Finally, all React components must return a value. My preference is to return an empty fragment, but you can return null or an empty string. There’s also nothing stopping you from actually returning visible output while listening for changes in the form, all in the same component.
return <></>
Example #2: Fetching Data
Listeners must be client components, but sometimes you want to populate a part of the form using the data from somewhere else within Payload. There are two ways to achieve this: Either by using Server Components, or by using the REST API.
In the following two examples, we have a simple delivery tracking system. We have two entities: DeliveryJob
and Address
.
An Address
contains the following fields:
The address itself
The name of the contact at the address.
A notes field for notes that the delivery driver should know when at the address
A DeliveryJob
contains a name, as well as an array of stops
the delivery driver must make.
A DeliveryJob
’s Stop row contains:
The address to deliver to
The notes of the stop. By default this will be the
Address
’s default notes, but users can change this in the form
It also contains a field for the last stop’s contact name, which should be populated by default.
Here is the collections config for DeliveryJob and Address if you want to look at the setup.
Example #2A: Using Server Components
The server component approach fetches the data in a server component, then passes its data onto a wrapped client component which does the listening. This appraoch is good if the data you’re fetching does not need to be refetched during the lifetime of the form UI (i.e. if another user saves the data that’s been fetched by the server component, it’s not the end of the world).
To demonstrate this, assume we have these business requirements:
All
DeliveryJobs
should start from the warehouse address (anAddress
with typeWarehouse
), therefore when the user opens up the form for the first time, thestops
array should be pre-populated with the first job having the warehouse’s addressIf the
stops
array is not empty, or we have deleted the pre-populated row in the UI, we should not re-add the pre-populated rowThe Warehouse address is not expected to change
The Server Component paradigm is suitable here because our Warehouse data doesn’t change often, therefore we can pre-render the component and save on fetch()
requests in the client.
Here’s the server component:
import type { UIFieldServerComponent } from 'payload'
import JobStopsListenerFieldEffect from './JobStopsListenerFieldEffect'
const JobStopsListenerField: UIFieldServerComponent = async ({ payload }) => {
const warehouse =
(
await payload.find({
collection: 'addresses',
where: {
type: {
equals: 'warehouse',
},
},
limit: 1,
})
).docs[0] ?? null
if (!warehouse) {
return null
}
return <JobStopsListenerFieldEffect warehouse={warehouse} />
}
export default JobStopsListenerField
The data is fetched in the Server Component, then passed down into the Client Component, where the listening happens:
'use client'
import { Address } from '@/payload-types'
import { useAllFormFields, useField, useForm, useFormFields } from '@payloadcms/ui'
import { useEffect, useState } from 'react'
import { reduceFieldsToValues } from 'payload/shared'
interface JobStopsListenerFieldEffectProps {
warehouse: Address
}
const JobStopsListenerFieldEffect = ({ warehouse }: JobStopsListenerFieldEffectProps) => {
const [addedInitialRow, setAddedInitialRow] = useState(false)
const { addFieldRow } = useForm()
const { formInitializing } = useField({
path: '',
})
const a = useFormFields(([fields]) => fields.stops)
console.log(a)
const [fields] = useAllFormFields()
const { stops } = reduceFieldsToValues(fields, true)
useEffect(() => {
// Don't run if form is still initializing
if (formInitializing) {
return
}
// Do nothing if the initial row has already been added
if (addedInitialRow) {
return
}
const inner = async () => {
// `stops as unknown === 0` is a workaround for bug https://github.com/payloadcms/payload/issues/10712
if ((stops as unknown) === 0 || stops?.length === 0) {
// insert Warehouse as the first row, if no stops exist
addFieldRow({
subFieldState: {
address: { initialValue: warehouse.id, value: warehouse.id, valid: true },
contactNotes: {
initialValue: warehouse.contactNotes,
value: warehouse.contactNotes,
valid: true,
},
},
path: 'stops',
schemaPath: 'stops',
})
}
// Prevent the event from firing again after the initial row has been added
setAddedInitialRow(true)
}
void inner()
}, [warehouse, stops, addFieldRow, formInitializing, addedInitialRow])
return <></>
}
export default JobStopsListenerFieldEffect
Just like in example #1, I’ll highlight the interesting portions of the code.
We start by accepting the warehouse prop from the server component:
interface JobStopsListenerFieldEffectProps {
warehouse: Address
}
const JobStopsListenerFieldEffect = ({ warehouse }: JobStopsListenerFieldEffectProps) => {
We add an internal state to the listener that will prevent the listener effect from being run if it’s already run:
const [addedInitialRow, setAddedInitialRow] = useState(false)
We use addFieldRow
from the useForm
hook as an alternative to dispatchField
. You can use either, though I find addFieldRow
’s function signature is nicer:
const { addFieldRow } = useForm()
This resolves the value of stops
in our form fields. Here we use useAllFormFields
, because there’s currently a limitation with useFormFields
where it doesn’t return the values of array field rows, only the row IDs. This isn’t great for performance as every field change will cause the component to re-render, but it’s the best we’ve got. GitHub discussion here.
const [fields, ] = useAllFormFields()
const { stops } = reduceFieldsToValues(fields, true)
We now enter the useEffect
portion:
useEffect(() => {
We don’t run the code if the form is still initializing, or if we’ve already added the row:
// Don't run if form is still initializing
if (formInitializing) {
return
}
// Do nothing if the initial row has already been added
if (addedInitialRow) {
return
}
We now have our main effect. We use addFieldRow
to add a new row to the stops
array field2, then call setAddedInitialRow(true)
to prevent newer rows from being added.
const inner = async () => {
// `stops as unknown === 0` is a workaround for bug https://github.com/payloadcms/payload/issues/10712
if ((stops as unknown) === 0 || stops?.length === 0) {
// insert Warehouse as the first row, if no stops exist
addFieldRow({
subFieldState: {
address: { initialValue: warehouse.id, value: warehouse.id, valid: true },
contactNotes: {
initialValue: warehouse.contactNotes,
value: warehouse.contactNotes,
valid: true,
},
},
path: 'stops',
schemaPath: 'stops',
})
}
// Prevent the event from firing again after the initial row has been added
setAddedInitialRow(true)
}
void inner()
Example #2B: Client-sided fetching
The client-sided fetching approach, unlike server components, fetches the data via either the REST API or the GraphQL API within the client, after the form is loaded in the client. This is good for scenarios where the data you’re trying to fetch changes often, but there is more boilerplate to contend with, or that you won’t know what data the UI needs to fetch until the user makes a choice.
To demonstrate this, assume we have these business requirements:
Populate the
lastStopContactName
field with thecontactName
of theaddress
of the last row instops
array.If there are no rows in the
stops
array, populate said field with the value “Nobody”.
The following example uses raw fetch(), but for production you should use TanStack Query or SWR instead.
'use client'
import { useAllFormFields, useField } from '@payloadcms/ui'
import { useEffect, useState } from 'react'
import { reduceFieldsToValues } from 'payload/shared'
const JobStopsLastStopListenerField = () => {
const [previousLastStopId, setPreviousLastStopId] = useState('')
const [fields, dispatchFields] = useAllFormFields()
const { stops, lastStopContactName } = reduceFieldsToValues(fields, true)
const { formInitializing } = useField({
path: '',
})
useEffect(() => {
// Don't run if form is still initializing
if (formInitializing) {
return () => {}
}
// Why we need this variable: https://devtrium.com/posts/async-functions-useeffect#note-on-fetching-data-inside-useeffect
let inflight = true
const dispatchNewValue = (value: string) => {
// Prevent infinite loops
if (lastStopContactName !== value) {
dispatchFields({
type: 'UPDATE',
path: 'lastStopContactName',
value,
})
}
}
const inner = async () => {
// `stops as unknown === 0` is a workaround for bug https://github.com/payloadcms/payload/issues/10712
if (!stops || (stops as unknown) === 0 || stops?.length === 0) {
// Emit a default value
dispatchNewValue('Nobody')
return
}
const lastStop = stops[stops.length - 1]
if (!lastStop.address) {
// they haven't filled out the address dropdown yet
return
}
if (lastStop.address === previousLastStopId) {
// Prevent infinite loops
return
}
// Other sanity checks omitted for brevity
const results = await (
await fetch(`/api/addresses?where[id][equals]=${lastStop.address as string}`)
).json()
if (inflight) {
dispatchNewValue(results.docs[0].contactName)
setPreviousLastStopId(lastStop.address as string)
}
}
void inner()
return () => {
inflight = false
}
}, [stops, lastStopContactName, formInitializing, previousLastStopId, dispatchFields])
return <></>
}
export default JobStopsLastStopListenerField
The function is fairly similar, except we now fetch the data from the local API in the browser instead of preloading it with server components. Some interesting features include:
We do a rudimentary check to prevent infinite loops when setting the default value “Nobody” when the listener is being loaded.
We cache the address ID that we last saw to save on round-trips.
There are some extra code to prevent us from firing too many API requests due to re-renders. This article goes into depth on how to
fetch()
data withinuseEffect
hooks, which is a great read. But once again, you should be using a library instead for production.
Conclusion
And that’s the Listener pattern in Payload. As everything is Just React™️, the standard idioms apply and thus makes Payload great for extensibility.
FAQs
How can I ensure listener A and listener B always run in sequence?
I haven’t had the need to do this yet. I would however suggest not depending on the field order as defined in the collection config to do this, however tempting this is.
If you find that the default behaviour (letting both listeners run in parallel) causes clashes, you might need to merge the two listeners into one.
Technically, you can use the dispatchFields
function from the useFormFields
hook like so:
const theFieldYouWant = useFormFields(([fields, ]) => (fields && fields?.theFieldYouWant) || null)
const dispatchFields = useFormFields(([, dispatch]) => dispatch)
but this function has no difference from what the useForm
hook returns, and I think it’s bad API design as it muddles up the return value of the hook. Here’s a GitHub discussion about it.
The subFieldState
should contain the fields you want to update (setting the initialValue
and value
to the desired value, and set valid: true
).
The path
is the dot-notation path to the field (including the 0-index), where schemaPath
is the same but references the path as laid out in your collection config. For example, if you want to reference the contactNotes
field of the 5th row of the stops
array field, the path would be stops.4.contactNotes
(remember 0-index), and the schemaPath would be stops.contactNotes
.