# Discord.js Starboard Bot: Dual-Emoji Setup Challenges and Solutions
## Summary
**Discord's starboard** traditionally monitors a single emoji to highlight popular messages. This article explores implementing a **dual-emoji starboard** (🎁 and 🪑) requiring distinct reaction thresholds (6 and 7). While initially intuitive, developers face critical **logic gaps** when handling multiple emojis simultaneously. We'll dissect a real-world implementation flaw and provide production-ready solutions.
## Root Cause
The core issue stems from **misinterpreting event handling** for Discord.js reactions. The original code attempts to validate BOTH emojis' thresholds during a SINGLE reaction event—an impossible scenario since each event concerns only one emoji.
The problematic condition:
```javascript
if ((emoji.name === CONFIG.MPREG.EMOJI && count == CONFIG.MPREG.LIMIT)
&&
(emoji.name === CONFIG.CHAIR.EMOJI && count == CONFIG.CHAIR.LIMIT)) {
// ...
}
Crucially: A reaction event fires per-emoji when users react. At runtime:
- A 🪑-reaction triggers with only 🪑 metrics
- A 🫃-reaction triggers with only 🫃 metrics
The&&condition can NEVER be satisfied—emoji.namecannot be two values simultaneously.
Why This Happens in Real Systems
-
Event-driven misunderstandings: Junior developers often conflate:
- State-based checks (“Are both thresholds met?”)
- Event-based triggers (“This specific reaction just changed”)
-
Partial state visibility:
Events report only changed emoji counts, not all reactions concurrently. -
Threshold synchronization:
Systems requiring compound conditions (Emoji A AND Emoji B) necessitate state persistence between events.
Real-World Impact
- 🚫 Starboard never triggers despite user reactions
- Unmet user expectations for “feature completeness”
- Developer friction debugging async interactions
- Critical path failure: Core community feature broken
Example Code Revised Implementation
Implement separate emoji checks with complete reaction state lookup. This solution:
- Handles emojis individually
- Cross-validates total counts using fetched message reactions
- Avoids partial event data traps
module.exports = async (reaction) => {
if (reaction.partial) await reaction.fetch().catch(() => { return; });
if (reaction.message.author.bot) return;
const { emoji, message } = reaction;
const botClient = message.client;
// Fetch FULL reaction state regardless of event hoisting issues
await message.fetch().catch(console.error);
// Find counts for BOTH target emojis
const mpregReaction = message.reactions.cache.get(CONFIG.MPREG.EMOJI);
const chairReaction = message.reactions.cache.get(CONFIG.CHAIR.EMOJI);
const mpregCount = mpregReaction?.count || 0;
const chairCount = chairReaction?.count || 0;
// Separate logic per emoji-and-threshold pair
const isMpregTrigger = emoji.name === CONFIG.MPREG.EMOJI
&& mpregCount === CONFIG.MPREG.LIMIT;
const isChairTrigger = emoji.name === CONFIG.CHAIR.EMOJI
&& chairCount === CONFIG.CHAIR.LIMIT;
// Only activate on ANY threshold hit
if (isMpregTrigger || isChairTrigger) {
const mpregchairChannel = botClient.channels.cache.get(CONFIG.MPREG.ID);
if (!mpregchairChannel) return;
const embed = new EmbedBuilder()
.setColor(0xEDDC24)
.setAuthor({ name: message.author.tag, iconURL: message.author.displayAvatarURL() })
.setDescription(message.content || " [No text content] ")
.addFields({ name: 'Channel', value: `${message.channel}`, inline: true })
.setFooter({ text: `Message ID: ${message.id}` })
.setTimestamp();
if (message.attachments.size > 0) {
embed.setImage(message.attachments.first().proxyURL);
}
// Notify with combined content
await mpregchairChannel.send({
content: `🫃🪑 | ${message.url}`,
embeds:
});
}
};
How Senior Engineers Fix It
-
Decouple emoji handling:
Treat reactions as independent events but validate against global state. -
Fetch complete message state:
Usemessage.fetch()post-reaction to overcome partial data limitations. -
Separate triggers:
Check thresholds per-emoji then OR-combine (||) triggers—not AND (&&). -
Defensive access:
Safeguard against missing reactions with optional chaining (?.). -
Idempotency guards:
Track message IDs to prevent duplicate starboard posts if bots restart. -
Configuration validation:
Add startup checks to verify.envIDs exist in guild context.
Why Juniors Miss It
- Event-driven blind spots: Assuming
reactionevents expose all reactions - Synchronous thinking: Expecting “at this moment” state represents persistent truth
- Incomplete docs: Discord.js doesn’t highlight
partialreaction caveats - Boolean logic pitfalls: Misapplying compound conditions across async events
- Testing gaps: Local trials often use single-emojis/accounts hiding dual-emoji bugs
Key Principle: Treat Discord reaction events as incremental state changes—not total state snapshots. Always fetch full message context before validation.