@Defer
Answer within Discord's three seconds, and lock a component's message while its handler works.
You'll learn
- Acknowledge before slow guards and handlers run
- Lock a component's message and put it back afterwards
- Choose when to acknowledge with mode, after and disable
Discord gives a handler three seconds to answer. @Defer() answers for it, in two steps,
so a slow guard or handler never misses that window, and a stranger's click never touches someone else's message.
When to use it
Put @Defer() on any interaction handler that might take more than a moment: one that calls a database or an API,
or sits behind a guard that does. On a button or a select menu, it also locks the message while the handler works,
so a user can't click twice.
Leave it off a handler that shows a modal, since a modal must be the first response, or use mode: 'auto' and show the
modal before after passes; and leave it off one that always answers at once. For a handler that answers quickly most
of the time, mode: 'auto' acknowledges only when it has to.
Example
@Command('card/{ownerId}/refresh', CommandType.BUTTON)
@UseGuard(OwnerGuard)
@Defer()
async refresh(interaction: ButtonInteraction) {
const card = new EmbedBuilder().setTitle('Refreshed').setTimestamp()
// Without `components`, the buttons come back as they were before the lock
await respond(interaction).send({ embeds: [card] })
}The click is acknowledged before any guard runs. Once the call is allowed, the card's buttons are disabled, the
clicked one shows the loading emoji, and "Working on it…" is added. send() then puts the buttons back as they were
and replaces the loading view with the answer.
How it works
- Before any guard runs,
@Deferacknowledges: a deferred reply for a command or a modal sent from one, shown as "thinking…", or an invisible deferred update for a button, a select menu or a modal from a message. - Once guards, validation, pipes and cooldowns have allowed the call, it locks a component's message: its controls are disabled, the clicked button shows the theme's loading emoji, and the presenter's loading view is added.
- When the handler answers,
send()withoutcomponentsputs the message's controls back as they were before the lock, a button disabled on purpose included, and removes the loading view.components: []clears them.
A handler that returns without answering has its message put back too. When a component's handler throws, the error is shown to the user privately, and the message is restored.
Guards under @Defer
The lock waits for the guards, so a refused click never locks the message, not even briefly. Here, OwnerGuard
lets only the user whose id the button carries use it:
import { type ButtonInteraction } from 'discord.js'
import { GuardDeniedError } from 'meocord/common'
import { Guard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
/** Lets only the user whose id the button carries use it: `card/{ownerId}/…` */
@Guard()
export class OwnerGuard implements GuardInterface {
canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
// Thrown, it is answered privately; returning false would deny silently
if (interaction.user.id !== ownerId) throw new GuardDeniedError('Only the user who opened this can use it.')
return true
}
}When someone else clicks, the guard throws GuardDeniedError, which is answered to them privately. A guard that
returns false leaves nothing behind: a command's deferred reply is deleted, and a component's message was never
touched.
import { ButtonInteraction, User } from 'discord.js'
import { GuardDeniedError } from 'meocord/common'
import { createMockInteraction, getResponse, MeoCordTestingModule } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { CardButtonController } from '@src/controllers/button/card.button.controller'
const click = (customId: string, userId: string) =>
createMockInteraction(ButtonInteraction, { customId, user: createMockInteraction(User, { id: userId }) })
describe('CardButtonController', () => {
const module = MeoCordTestingModule.create({ controllers: [CardButtonController] }).compile()
it('acknowledges before the guard, then answers the owner with an edit', async () => {
const interaction = click('card/111/refresh', '111')
await module.invoke(CardButtonController, 'refresh', interaction)
expect(getResponse(interaction).calls.map(call => call.method)).toEqual(['deferUpdate', 'editReply'])
})
it('refuses a stranger before the handler runs, and never touches the message', async () => {
const interaction = click('card/111/refresh', '222')
// The bot answers a GuardDeniedError privately; in a test, where no fallback runs, invoke rejects with it
await expect(module.invoke(CardButtonController, 'refresh', interaction)).rejects.toThrow(GuardDeniedError)
expect(getResponse(interaction).calls.map(call => call.method)).toEqual(['deferUpdate'])
})
it('answers a fast handler with a single update under auto', async () => {
const interaction = click('card/111/like', '111')
await module.invoke(CardButtonController, 'like', interaction)
expect(getResponse(interaction).calls.map(call => call.method)).toEqual(['update'])
})
})Options
| Option | Default | What it does |
|---|---|---|
ephemeral | false | Makes a command's deferred reply private. |
disable | 'all' | 'clicked' disables only the control used, so the others stay usable. 'none' skips the lock. |
mode | 'eager' | 'auto' acknowledges only if the handler hasn't answered after after milliseconds. |
after | 1500 | For 'auto'; never later than 2.5 seconds after the interaction was created. |
suppressNotifications | false | New messages, such as a first reply sent before 'auto' acknowledged, and follow-ups, don't notify. |
Code that locks a message itself calls respond(interaction).lock(), which takes disable the same way; its options
type is ResponseLockOptions from meocord/common.
Acknowledging only when needed
Under 'auto', a handler that answers quickly answers with one reply or update, and nothing is locked:
// Answers at once, so it replies with one update and nothing is ever locked
@Command('card/{ownerId}/like', CommandType.BUTTON)
@Defer({ mode: 'auto' })
async like(interaction: ButtonInteraction) {
await respond(interaction).send({ content: 'Liked.' })
}A slow one is acknowledged at 1.5 seconds and then locked, as under 'eager'.
Locking only the clicked control
With disable: 'clicked', two buttons of one message can run at once. Each is disabled while its own handler runs and
comes back when that handler finishes. The loading view stays until both have finished, unless one answers with
send(), whose answer replaces it.
// Disables only the clicked button, so the others stay usable while it runs
@Command('card/{ownerId}/export', CommandType.BUTTON)
@Defer({ disable: 'clicked' })
async export(interaction: ButtonInteraction) {
await respond(interaction).followUp({ content: 'Your export is ready.', flags: MessageFlags.Ephemeral })
}The lock in a test
A test sees the lock as the calls respond() made. The message the button sits on is a createMockMessage() given
the controls it shows, as Discord's JSON:
const card: APIActionRowComponent<APIComponentInMessageActionRow>[] = [
{
type: ComponentType.ActionRow,
components: [
{ type: ComponentType.Button, style: ButtonStyle.Primary, custom_id: 'card/111/refresh', label: 'Refresh' },
{ type: ComponentType.Button, style: ButtonStyle.Secondary, custom_id: 'card/111/export', label: 'Export' },
],
},
{
type: ComponentType.ActionRow,
components: [
{ type: ComponentType.StringSelect, custom_id: 'card/111/sort', options: [{ label: 'Newest', value: 'new' }] },
],
},
]
// A click on a message showing the card: createMockMessage takes its components as API JSON
const clickOnCard = (customId: string) =>
createMockInteraction(ButtonInteraction, {
customId,
user: createMockInteraction(User, { id: '111' }),
message: createMockMessage({ components: card }),
})it('locks every control while the handler runs, then puts them back', async () => {
const interaction = clickOnCard('card/111/refresh')
await module.invoke(CardButtonController, 'refresh', interaction)
expect(getResponse(interaction).calls.map(call => call.method)).toEqual(['deferUpdate', 'editReply', 'editReply'])
const [, lock, answer] = payloads(interaction)
// The lock: both buttons and the select disabled, the clicked button showing ⏳, the loading view added
expect(lock.components!.flatMap(row => row.components.map(control => control.disabled))).toEqual([true, true, true])
expect(lock.components![0].components[0].emoji).toEqual({ name: '⏳' })
expect(lock.embeds!.at(-1)?.description).toBe('⏳ Working on it…')
// The answer: send() without components restores them as they were, and drops the loading view
expect(answer.components).toEqual(card)
expect(answer.embeds!.some(embed => embed.description === '⏳ Working on it…')).toBe(false)
})Gotchas
- Answer through
respond().interaction.reply()fails once@Deferhas acknowledged;respond()edits the deferred reply instead. @Deferis for interaction handlers. On a message, reaction, event or autocomplete handler, it's refused in one line, as the controller loads when it's written above the handler's decorator, and as the app is created when it's below:Chat.hi: @Defer is for interaction handlers, and this is a message handler. Remove @Defer from it.- A modal must come first. Leave
@Deferoff a handler that shows a modal, or usemode: 'auto'and show it beforeafterpasses.
Next steps
- Answering with respond(): every call
send()and its siblings can make. - Presenters: change the loading view
@Deferadds. - Guards: refuse a call before
@Deferlocks anything.