Stop Parsing Text Commands: Interactive Flows with discord.js Component Collectors
Discord buttons beat text-based confirmation commands. Here's how discord.js component collectors keep per-message state local, with a worked confirm/cancel ban flow and the restart trade-off.
05 Feb 2026, 03:50 UTC

Your moderation bot needs a confirmation step before it bans someone. The old-school answer is a second text command — !confirm 12345 — which means storing pending actions, matching IDs, and hoping nobody typos. Discord's message components solve this directly: put a Confirm and a Cancel button on the message itself, and let Discord deliver the click back to you as a structured interaction.
The piece that makes this clean in discord.js is the component collector. Instead of writing a global interactionCreate handler that has to figure out which button click belongs to which pending action, you attach a collector to the specific message you just sent. It listens only for that message's components, for a time window you choose, and then ends. Per-message state stays per-message.
The three rules that make or break component flows
Before the example, three constraints cause nearly all the bugs people hit with buttons:
- The 3-second acknowledgement deadline. Every component interaction must be acknowledged within about 3 seconds using
reply,update,deferReply, ordeferUpdate. Miss it and the user sees "This interaction failed" — the single most common component error. - Filter to the invoking user. Anyone who can see the message can click its buttons. Collectors accept a
filterfunction; comparinginteraction.user.idto the original command author's ID is the standard way to stop hijacking. - Collectors are in-memory only. If your bot restarts, the collector is gone and clicks on the old message fail. For anything that must survive restarts, you need a persistent handler keyed on
customIdinstead.
Worked example: a confirm/cancel ban flow
This assumes discord.js v14 (check with npm list discord.js — builder and collector APIs have changed across major versions, so confirm the names against what you have installed). The command replies with a button row, collects one click from the invoking user within 30 seconds, and disables the buttons when it's done.
const {
ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType
} = require('discord.js');
// Inside your slash command handler for /ban:
async function execute(interaction) {
const target = interaction.options.getUser('user');
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('ban:confirm')
.setLabel('Confirm ban')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('ban:cancel')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary),
);
const message = await interaction.reply({
content: `Ban ${target.tag}? This cannot be undone.`,
components: [row],
fetchReply: true, // needed so we can attach the collector
});
const collector = message.createMessageComponentCollector({
componentType: ComponentType.Button,
filter: (i) => i.user.id === interaction.user.id,
time: 30_000,
});
collector.on('collect', async (i) => {
if (i.customId === 'ban:confirm') {
await interaction.guild.members.ban(target.id);
await i.update({ content: `${target.tag} was banned.`, components: [] });
} else {
await i.update({ content: 'Ban cancelled.', components: [] });
}
collector.stop();
});
collector.on('end', async (collected, reason) => {
if (reason === 'time' && collected.size === 0) {
// Nobody clicked: disable the stale buttons.
await interaction.editReply({
content: 'Ban confirmation timed out.',
components: [],
});
}
});
}A few things worth noticing. i.update(...) does double duty: it acknowledges the interaction and edits the original message in one call, which is exactly what you want for buttons. Passing components: [] removes the row entirely, so the button can't be clicked twice. And the end event is the natural cleanup point — when the timer expires with no clicks, you edit the message so the stale UI doesn't sit there looking clickable.
customId is your routing mechanism
The customId string is how you know what was clicked. A useful convention is encoding an action and context, like ban:confirm:123456789, so a single collector or handler can dispatch many flows without a separate state map. Two limits to respect: customId is capped at 100 characters, so don't stuff JSON payloads into it, and anything you encode is visible to clients, so don't put secrets there.
The trade-off: collectors vs. a persistent handler
Collectors are the right tool for short-lived, single-message flows — confirmations, paginated embeds, quick polls. They're simple because the state lives in the closure. The cost is exactly that: the state lives in the closure. Restart the bot and every outstanding collector vanishes; clicks on those old messages then fail with "interaction failed." Buttons on ephemeral messages also behave differently from public ones, so test your specific pattern.
If a button must work hours later or across restarts — a ticket-open button on a permanent panel, say — skip the collector. Handle interactionCreate globally, route on customId, and keep any needed state in a database. Many bots use both: persistent handlers for permanent UI, collectors for ephemeral flows.
Verify it yourself
You can check the whole flow in a test server in a few minutes: run /ban against a test account and confirm the click bans and edits the message; let a second command instance expire untouched and confirm the buttons disappear; and have a different user click the button — the filter should silently ignore them (or, if you add an else branch, send them an ephemeral "this isn't your confirmation" reply). If all three behave, your collector logic is sound.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.