Skip to content
GitHub

@Defer

MeoCord 4.2 · Handling interactions · page 11 of 41 · since 4.1.0

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

controllers/button/card.button.controller.ts
@Command('card/{ownerId:snowflake}/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

  1. Before any guard runs, @Defer acknowledges: 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.
  2. 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.
  3. When the handler answers, send() without components puts 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:

guards/owner.guard.ts
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:snowflake}/…` */
@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.

controllers/button/card.button.controller.spec.ts
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/111111111111111111/refresh', '111111111111111111')

    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/111111111111111111/refresh', '222222222222222222')

    // 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/111111111111111111/like', '111111111111111111')

    await module.invoke(CardButtonController, 'like', interaction)

    expect(getResponse(interaction).calls.map(call => call.method)).toEqual(['update'])
  })
})

Options

OptionDefaultWhat it does
ephemeralfalseMakes 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.
after1500For 'auto'; never later than 2.5 seconds after the interaction was created.
suppressNotificationsfalseNew 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:

controllers/button/card.button.controller.ts
// Answers at once, so it replies with one update and nothing is ever locked
@Command('card/{ownerId:snowflake}/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.

controllers/button/card.button.controller.ts
// Disables only the clicked button, so the others stay usable while it runs
@Command('card/{ownerId:snowflake}/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:

controllers/button/card.lock.spec.ts
const card: APIActionRowComponent<APIComponentInMessageActionRow>[] = [
  {
    type: ComponentType.ActionRow,
    components: [
      {
        type: ComponentType.Button,
        style: ButtonStyle.Primary,
        custom_id: 'card/111111111111111111/refresh',
        label: 'Refresh',
      },
      {
        type: ComponentType.Button,
        style: ButtonStyle.Secondary,
        custom_id: 'card/111111111111111111/export',
        label: 'Export',
      },
    ],
  },
  {
    type: ComponentType.ActionRow,
    components: [
      {
        type: ComponentType.StringSelect,
        custom_id: 'card/111111111111111111/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: '111111111111111111' }),
    message: createMockMessage({ components: card }),
  })
controllers/button/card.lock.spec.ts
it('locks every control while the handler runs, then puts them back', async () => {
  const interaction = clickOnCard('card/111111111111111111/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 @Defer has acknowledged; respond() edits the deferred reply instead.
  • @Defer is 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 @Defer off a handler that shows a modal, or use mode: 'auto' and show it before after passes.

Next steps