Stop Routing Every discord.js Button Through One Giant Interaction Handler
A giant InteractionCreate listener makes multi-step button flows painful. discord.js v14 message component collectors keep prompts local, filtered, and bounded — here's a working confirm/cancel pattern and its limits.
04 Feb 2026, 12:11 UTC

Your bot has a settings menu, a pagination control, and a confirm/cancel prompt. All three are buttons. If every click lands in one client.on(Events.InteractionCreate) listener, you end up with a growing if (customId === ...) chain, plus awkward state passing between steps. There is a cleaner pattern built into discord.js v14: the MessageComponentCollector. It listens only for components on a specific message, for a bounded time, with a filter you control — and it keeps multi-step prompts readable.
The thesis is simple: use the global interaction listener for one-shot commands, and use collectors for anything conversational. This post assumes discord.js v14 (the builder and enum names changed from v13, so check package.json before copying older examples).
Why the giant listener breaks down
A global listener works fine until a flow needs more than one click. Imagine a two-step prompt: pick a region from a select menu, then confirm with a button. With only the global listener, you must stash "user 123 picked region eu-west" somewhere — a Map, a database row — and remember to clean it up. You also have to namespace every customId carefully so the settings menu's confirm button doesn't collide with the pagination one.
A collector inverts this. When you send the prompt message, you attach a collector to that message. It resolves when the flow ends (time, idle, or explicit stop), and its closure already holds all the local state. No global Map, no customId collisions across features.
A worked example: confirm/cancel with a collector
This runs inside a slash command's execute function. It posts a confirmation, collects exactly one click from the invoking user, and always ends on a single terminal path.
const {
ActionRowBuilder, ButtonBuilder, ButtonStyle,
ComponentType
} = require('discord.js');
async function askConfirmation(interaction) {
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('confirm-delete')
.setLabel('Delete')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('cancel-delete')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary),
);
const response = await interaction.reply({
content: 'Delete this record? This cannot be undone.',
components: [row],
fetchReply: true,
});
const collector = response.createMessageComponentCollector({
componentType: ComponentType.Button,
filter: (i) => i.user.id === interaction.user.id,
time: 30_000,
max: 1,
});
collector.on('collect', async (i) => {
if (i.customId === 'confirm-delete') {
// Re-check authorization and re-fetch state here.
await i.update({ content: 'Deleted.', components: [] });
} else {
await i.update({ content: 'Cancelled.', components: [] });
}
});
collector.on('end', (collected, reason) => {
if (collected.size === 0 && reason === 'time') {
interaction.editReply({ content: 'Timed out.', components: [] })
.catch(() => {}); // message may already be gone
}
});
}Three details matter here. First, the filter restricts the collector to the user who ran the command — without it, anyone in the channel can click. Second, max: 1 plus a time limit guarantees the collector terminates; a collector created per interaction with no limit is a slow memory leak. Third, the end handler distinguishes "user clicked" from "timed out" by checking collected.size, so exactly one terminal path runs.
The 3-second acknowledgement rule still applies
Collectors don't exempt you from Discord's interaction deadline. Every component interaction must be acknowledged within roughly three seconds using one of: reply, deferReply, update (for components, editing the original message), or showModal. After deferReply, use editReply or followUp — calling reply again throws.
In the example, i.update() both acknowledges the click and edits the prompt in place. If your confirm handler needs to do slow work (a database migration, an external API call), call await i.deferUpdate() first, then i.editReply() when finished. You can verify the deadline behavior in a staging bot: add an artificial await new Promise(r => setTimeout(r, 4000)) before acknowledging and watch Discord show "This interaction failed" to the clicking user.
customId is untrusted input, not a state bag
Two habits cause real bugs in component code:
- Serializing objects into customId. Discord caps customId at 100 characters, and anything in it is attacker-controlled. A user with a modified client can send any customId. Keep it a stable, namespaced string like
settings:region, and re-fetch the actual record server-side. - Trusting the click. Authorize
i.user.idagainst your own rules even inside a filtered collector — the filter narrows who triggers your handler, it doesn't prove the user still has the role they had when the menu was posted. Also handle the original message being deleted:update()on a vanished message rejects, so wrap terminal edits in.catch()as in the example.
On intents: component interactions on your own bot's messages generally need only GatewayIntentBits.Guilds. You do not need the privileged MessageContent intent unless you read other users' message text — don't enable it just for buttons.
The trade-off
Collectors are tied to a message and a process. If the bot restarts mid-prompt, the collector is gone and the buttons become dead — clicking them produces "interaction failed" unless your global listener has a fallback that replies ephemerally with something like "this menu expired, please run the command again." For menus that must survive restarts (ticket panels, persistent role buttons), route those customIds through the global listener with state stored in a database instead. The practical split: collectors for short-lived, per-invocation prompts; the global listener for durable, stateless components.
Try it and check the terminal path
Port one multi-step prompt from your global listener to a collector this week. Then verify it behaves: click the button and confirm update() edits the original message rather than posting a new one; let it sit for 30 seconds and confirm the timeout branch fires exactly once; and log the end event's reason (time, idle, user, limit) to prove no duplicate handlers accumulate across invocations. If your logs show one clean terminal event per prompt, the migration worked.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.