TL;DR fluxcord is a stateful Discord UI framework I extracted out of my own bot. Screens are declared in TSX and buttons bind to plain functions instead of string IDs, so the framework can take over rendering, event routing, and expiry while your state lives in a per-session data bag.
📦 Repo | 📖 Full guide | ⚡ npm,npm install fluxcordThe rest of this post is how my own bot code forced this thing into existence, plus one section on what a JSX runtime actually is, because I didn't know either when I started.
Two months away, and I couldn't read my own code
Last February I started a Discord bot for a gaming community I'm in. It was nothing fancy at first, just slash commands and a few interactive panels. I spent the spring shipping modules and feeling quietly proud of the UI system I'd built.
Then life happened, and I stepped away for about two months.
When I came back in July, I knew what the bot did, but I had no idea how it did it. I spent that month running audits on my own project, essentially reverse-engineering decisions I'd made in March. Finding the right file was no longer faster than grepping, so I had to rely on an AI assistant just to navigate the codebase. The logic itself wasn't buggy, but it was unreadable, and I hadn't appreciated the difference before.
But I knew exactly who to blame, because I'd built the architecture myself on day eleven of the project.
Eleven days in, I wrote a spec for my own UI engine
Discord's building blocks are low-level: messages, embeds, components with custom IDs. If you want screens with navigation, you have to build it yourself. So on day eleven, I wrote a 590-line spec document to build my own UI engine.
The design made sense at the time, and honestly, parts of it were genuinely fine:
- Each page was an object with a string id, an async
render(context)function, and a map of click handlers. - Pages were built with a
PageBuilderthat built an embed from a title, description, fields, and buttons. And these pages were registered into a global Map. - Navigation was a stack of page ids, and a breadcrumb system derived a jump-nav from it. That breadcrumb file eventually became the biggest file in the engine.
It felt awesome at first. I had a working navigation system. But the problems were buried in the implementation details. Here's an excerpt from a real page from that engine:
export const syncCommandsPage: UiPage = {
id: ids.core.pages.SYNC_COMMANDS, // 'core/sync-commands', from an ID registry
moduleId: 'core',
gated: true, // the actual permission rule lives in a different file
async render(context) {
const data = context.data as SyncCommandsData;
const builder = new PageBuilder();
// ...
return builder
.setTitle('🔄 Sync Commands')
// ...
.addButton({
customId: encodeCustomId({ namespace: 'ui', contextId: context.id,
type: 'btn', action: ids.core.actions.SYNC_COMMANDS.START }),
label: 'Start Sync',
style: ButtonStyle.Primary,
});
},
};
Every button required a hand-packed encodeCustomId call to build a colon-delimited string that pointed to an action in a registry file. And nothing re-rendered automatically either. When a click changed something, the handler had to fetch its own page back out of the registry, call render again, and then manually call editReply to push the edit to Discord:
handlers: {
buttons: {
[ids.core.actions.SYNC_COMMANDS.START]: {
run: async (interaction, context) => {
data.syncing = true;
// ...
// the handler re-renders its own page, by hand
const page = getPage(ids.core.pages.SYNC_COMMANDS);
if (page) {
const rendered = await page.render(context);
await interaction.editReply(rendered);
}
return { action: 'stay' };
},
},
},
},
Rendering, state, handlers all lived in one 200-line object, while permission rules (a permission engine gated access) lived in a completely separate policies.ts file keyed by the same registry ids:
// policies.ts
pages: {
[pages.SYNC_COMMANDS]: { owner: { ownerOnly: true } },
},
The page carried a gated: true marker, and a boot-time check cross-referenced both files to ensure they were in sync. It was a lot of defensive code for a coordination problem my own design created. None of this looked like a mistake in March, but by July it was entirely unmanageable, or at least very hard to maintain.
I wrote a validator to police strings that shouldn't exist
The custom IDs went through three distinct eras before the rewrite began on August 31.
Generation one relied on raw strings. Page ids and action names were typed inline, 'core/sync-commands', 'start'. Typos were silent, which means any click that missed its handler simply vanished.
Generation two introduced an ID registry. Every module got an ids.ts file built with a helper, and these files were collected by a central registry file. The strings were replaced with references like ids.core.actions.SYNC_COMMANDS.START. I also wrote a boot validator that made sure the strings didn't collide. This validator only existed because actions were still strings managed by hand, and a collision would cause issues.
As for generation three, it was a wrong turn I took while trying to fix the previous issue in the new UI framework. I kept the strings, but I made them branded using a defineActions helper:
const actions = defineActions('lotto/main', ['join', 'leave']);
button({ action: actions.join, ... });
But the same issue remained, because I was still hand-listing every action by hand, and there was no way to enforce any type safety. The answer eventually became obvious: they shouldn't be strings. The handler function is the ID.
const join = action<LottoData>()(e => {
e.mutate(d => { d.joined = true; });
});
// in a screen:
<Button label="Join" onClick={join} />
join is a function reference, and two functions can't collide. A typo becomes a compile error, and the wire format the framework generates is stamped with a hash derived from the handler's source, so Discord's click routes back to the right function in the right session. I deleted the refs file and gutted the boot validator. I was able to cut down hundreds of lines of code from the main bot application with this refactor.
As for permissions, they were streamlined the same way. The policy now sits in the flow's own metadata (a flow groups your screens and their state) next to the code it protects, so the second file also went away.
What TSX actually compiles to
Somewhere in the middle of the rewrite I hit the part I genuinely didn't understand: if I wanted screens declared in TSX, I'd need a JSX runtime. I'd used React without knowing what that was. So in case you're where I was, here's a simplified version of the whole thing.
You write a screen as a function of state:
const hello = action<SampleData>()(e => {
e.mutate(d => { d.text = 'Hello'; });
});
const exampleScreen = screen<SampleData>()((data, { Button }) => (
<view>
<text>{data.text}</text>
<row>
<Button label="Say hello" onClick={hello} />
</row>
</view>
));
The TypeScript compiler turns every element into a function call. With jsxImportSource pointed at fluxcord/jsx-runtime, this is roughly what that screen becomes:
import { jsx } from 'fluxcord/jsx-runtime';
const exampleScreen = screen<SampleData>()((data, { Button }) =>
jsx('view', {
children: [
jsx('text', { children: data.text }),
jsx('row', {
children: jsx(Button, { label: 'Say hello', onClick: hello }),
}),
],
}),
);
That's the entire trick. The jsx function takes a tag, some props, and children, and returns a plain object describing that node. JSX is not React, and it's just syntax sugar for writing nested function calls that produce a tree. The JSX runtime is just whatever function those compiled calls route to. Lowercase tags like view and row are the framework's intrinsic tree nodes, whereas capitalized ones like Button come from a UI kit the screen factory hands you. The guide covers the kit in depth.
So basically, the renderer walks the tree, validates it against Discord's component rules, and converts it into payloads for Discord's Components v2 on a real message. There is no virtual DOM or HTML or anything like that, and there's also no diffing.
The reason fluxcord has no diffing is because Discord messages are edited through the API, so there's nothing to diff against. It's literally one call per update. When a click happens, the framework mutates the session's data bag, re-runs the screen function, gets a fresh tree, and edits the message. Whereas React needs reconciliation because the DOM is expensive to touch.
But the most important part of all of this is of course the navigation system. Navigation is handled by navigation verbs like e.ui.go('receipt'), and the screen keys are checked at compile time, so go('reciept') doesn't pass the build stage. And because the kit's Button binds the handler function directly, there is no ID to remember anywhere, which is the whole lesson of the previous section arriving in the API.
What it looks like now
The same sync panel, after:
const idleView = subview<SyncPanelData>()((data, { Button }) => (
<view>
<text title="Sync commands">...</text>
<row>
<Button onClick={startSync} label="Start sync" />
</row>
</view>
));
export const syncFlow = flow<SyncPanelData>('sync', {
screens: { main: panelScreen },
// ...
}, {
policy: { owner: { ownerOnly: true } },
});
The registry import, the encodeCustomId call, the computed handler key, the gated marker, and the separate policy file are all gone. And the new panel got an upgrade the old engine couldn't express cleanly: the sync job itself pushes progress updates through the session handle as it works, serialized on the same queue as clicks. The old panel only updated when a handler remembered to re-render it.
With this new system, I was able to cut down around 9,500 lines of code from the main bot application, which now imports fluxcord directly instead.
Sessions, expiry, and one queue per panel
The old engine had sessions too, but they were mostly a context object passed around by hand. The rewrite made the session the core abstraction.
A session is created when a flow is mounted, and it clones the flow's initialData into a private data bag. It also keeps track of which Discord message is the panel and when it expires. For non-ephemeral panels, every interaction bumps the timestamp, creating a sliding TTL window, and once that TTL is up, a background sweeper ends the session and cleans up the panel, replacing the message with a parting screen.
That parting screen is customizable, and the default is just the "frozen" state of the last frame, meaning the same panel stays the same but all interactive controls are stripped out. If you wish to leave a final view behind, maybe a goodbye screen for example, you can define one and pass it to the flow's parting.view option.
The queue is the other load-bearing decision. Every event for a session, clicks and redraws alike, serializes through one queue, so a click can never race a redraw or another click mutating the same bag. This tradeoff only matters when a handler itself awaits slow work, since clicks then wait behind it. The way around it is a pattern rather than a framework feature: flip the state, hand the slow work to a job, and let the job push redraws through the queue instead. That's what the sync panel above does.
The bigger scaling limit sits elsewhere. Every draw is a REST call to the Discord API. You won't notice at hobby scale, but it's the first thing to fix at serious scale.
A closing note
fluxcord is MIT and it's on npm as fluxcord. The full guide walks through a working example bot: https://github.com/PhoenXHO/fluxcord
If you think this framework is a good fit for your bot, I'd genuinely be thrilled to see it in action. If you have any suggestions for improvement or find a bug, please let me know. Feedback is very much appreciated.
Top comments (0)