Mastering InteractionCollectors in Discord.js v14: Clean, Scalable Button & Select Menu Handling
Learn how to use Discord.js v14’s InteractionCollector for clean, scalable button and select menu handling. A step‑by‑step pagination example, trade‑offs, and a practical checklist keep your bot efficient and bug‑free.
09 Feb 2026, 14:31 UTC

Why InteractionCollectors Matter
When a command spawns buttons or select menus, the naïve approach is to attach a separate interactionCreate listener for each interaction. That quickly turns into a tangled web of callbacks, hard‑to‑trace bugs, and stale collectors that never clean up. Discord.js provides InteractionCollector to solve this: it scopes the listening to a specific message, applies a filter, and can automatically dispose of itself when a condition is met.
What an InteractionCollector Is
An InteractionCollector is a wrapper around the Client#on('interactionCreate') event that:
- Listens only to interactions that target a particular message.
- Runs a user‑supplied filter function to decide which interactions to collect.
- Accepts
max(number of interactions) andtime(timeout in ms) options to auto‑stop. - Emits
collectevents for each matched interaction and anendevent when it stops. - Automatically removes itself from the event listeners once it ends, preventing memory leaks.
Building a Pagination Command
Below is a minimal, ready‑to‑paste example that demonstrates how to use an InteractionCollector to create a paginated embed with two buttons: ◀️ for previous and ▶️ for next. The collector stops after 60 seconds of inactivity or after 10 page changes.
const { Client, GatewayIntentBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
const pages = [
'Page 1 content',
'Page 2 content',
'Page 3 content',
'Page 4 content',
'Page 5 content'
];
client.once('ready', () => console.log(`Logged in as ${client.user.tag}`));
client.on('interactionCreate', async interaction => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName !== 'paginate') return;
let currentPage = 0;
const embed = new EmbedBuilder()
.setTitle('Pagination Demo')
.setDescription(pages[currentPage])
.setFooter({ text: `Page ${currentPage + 1} of ${pages.length}` });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('prev').setLabel('◀️').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('next').setLabel('▶️').setStyle(ButtonStyle.Primary)
);
const message = await interaction.reply({ embeds: [embed], components: [row], fetchReply: true });
const filter = i => i.user.id === interaction.user.id && i.message.id === message.id;
const collector = message.createMessageComponentCollector({ filter, time: 60000, max: 10 });
collector.on('collect', async i => {
if (i.customId === 'prev') currentPage = Math.max(0, currentPage - 1);
if (i.customId === 'next') currentPage = Math.min(pages.length - 1, currentPage + 1);
const newEmbed = new EmbedBuilder()
.setTitle('Pagination Demo')
.setDescription(pages[currentPage])
.setFooter({ text: `Page ${currentPage + 1} of ${pages.length}` });
await i.update({ embeds: [newEmbed], components: [row] });
});
collector.on('end', (collected, reason) => {
console.log(`Collector ended: ${reason}. Collected ${collected.size} interactions.`);
// Disable buttons after collector ends
const disabledRow = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('prev').setLabel('◀️').setStyle(ButtonStyle.Primary).setDisabled(true),
new ButtonBuilder().setCustomId('next').setLabel('▶️').setStyle(ButtonStyle.Primary).setDisabled(true)
);
message.edit({ components: [disabledRow] });
});
});
client.login('YOUR_BOT_TOKEN');
Key points in the example:
filterensures only the original user can interact and that the interaction targets the same message.- The collector is created with
time: 60000(60 seconds) andmax: 10to automatically stop after 10 page changes. - When the collector ends, we disable the buttons so users know the session has expired.
- All interactions are handled inside the collector’s
collectevent; no extra global listeners are needed.
Trade‑Offs & Limitations
While InteractionCollector simplifies the code, there are a few caveats:
- Memory Footprint: The collector keeps a reference to every collected interaction until it stops. If you forget to set
timeormax, it will stay alive forever, potentially leaking memory in a long‑running bot. - Intent Requirements: The bot must have the
GUILD_INTERACTIONSintent (or the more specificGUILD_MESSAGE_REACTIONSfor button interactions in older API versions). Without it, the collector will never receive interactions. - Collector Scope: It only listens to interactions that target the specific message the collector was attached to. If you need a global button handler (e.g., a persistent menu that appears on multiple messages), you’ll need a different strategy.
Practical Checklist Before Deployment
- Verify your bot has the required intents in the Discord developer portal and in your code.
- Always set
timeormax(or both) when creating a collector. - Use
collector.stop()at the end of your logic if you need to terminate early. - Listen to the
endevent to clean up UI (e.g., disable buttons) and log the reason for debugging. - Test in a private server: trigger the command, interact, wait for the timeout, and confirm no further
interactionCreatelogs occur for that message.
Conclusion
By scoping interaction handling to a single message with InteractionCollector, you avoid scattered listeners, reduce the risk of stale collectors, and keep your bot’s memory usage predictable. Just remember to configure timeouts or limits and to clean up UI when the collector ends. With this pattern, you can build complex, user‑friendly interfaces—like pagination, forms, or multi‑step wizards—without sacrificing maintainability.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.