Nubis / Components
Bauen
Components
Buttons and select menus with typed, validated state, and how Nubis routes clicks back to them.
Die Nubis-Dokumentation ist auf Englisch, wie die API des Frameworks und seine Meldungen.
A component is defined once, in components/, and called like a function wherever you send it. The call takes its state; the handler gets that state back, typed, when someone clicks.
// components/confirm-ban.ts
import { button, invokerOnly } from "nubis";
import { z } from "zod";
export const confirmBan = button({
id: "confirm-ban",
label: "Confirm",
style: "danger",
state: z.object({ userId: z.string() }), // validated on every click
guards: [invokerOnly], // only whoever ran the command can click
onClick: async ({ state, api, interaction, update }) => {
await api.guilds.banUser(interaction.guild_id!, state.userId);
await update({ content: `Banned <@${state.userId}>.`, components: [] });
},
});// commands/ban.ts
import { confirmBan } from "../components/confirm-ban";
run: ({ options, reply }) =>
reply({
content: `Ban ${options.user.username}?`,
components: [confirmBan({ userId: options.user.id })],
ephemeral: true,
}),State in the custom_id
By default the state travels inside the component's custom_id, so it survives restarts and needs no database. Discord allows 100 characters there; Nubis throws when a state doesn't fit, naming the component, rather than letting Discord reject the message.
Anyone can craft a custom_id, so state you act on — user ids, amounts — should go through a schema. Any Standard Schema library works: Zod, Valibot, ArkType. When a click carries state the schema rejects, the handler doesn't run.
For state that is only displayed, stateOf<T>() types it without validating:
import { button, stateOf } from "nubis";
export const counter = button({
id: "counter",
label: "Click me",
state: stateOf<{ count: number }>(),
onClick: ({ state, update }) => {
const count = state.count + 1;
return update({ content: `Clicked ${count} times`, components: [counter({ count })] });
},
});Larger state
For state over 100 characters, give the component a store. The state is kept there and the custom_id carries only a key:
import { button, memoryStore } from "nubis";
const store = memoryStore(); // in this process, for a day by default
export const page = button({ id: "page", label: "Next", state: pageState, store, onClick });memoryStore() is lost on restart and isn't shared between processes. A StateStore is any object with get(key) and set(key, value), so Redis or a database table works the same way. When a key has expired, the click is treated as stale.
The handler context
| Field | What it is |
|---|---|
state | The state the component was sent with, typed. |
update(message) | Edits the message the component is on. |
reply(message) | Sends a new message. |
editReply(message) | Edits the first reply. |
defer({ ephemeral }) | Shows "thinking…" for a reply to come. |
deferUpdate() | Acknowledges without a message; update() can still edit later. Nubis does this on its own after two seconds. |
showModal(modal) | Opens a modal. |
api, interaction | Discord's API and the raw interaction. |
Buttons
button({ id, label, style, emoji, state, store, guards, middleware, onClick }). Styles are primary, secondary, success and danger. A sent button can override its label, style, emoji or disabled: counter({ count }, { disabled: true }).
linkButton({ url, label }) opens a URL. It has no handler and no state.
Select menus
import { stringSelect } from "nubis";
export const pickColor = stringSelect({
id: "pick-color",
placeholder: "Choose a colour",
options: [
{ label: "Red", value: "red" },
{ label: "Blue", value: "blue" },
],
onSelect: ({ values, update }) => update(`You picked ${values.join(", ")}.`),
// values: ("red" | "blue")[]
});userSelect, roleSelect, channelSelect and mentionableSelect pick Discord entities. Their handlers get users (and members), roles, channels, or users and roles, already resolved. All selects take minValues, maxValues and placeholder.
Who can click
Anyone who can see a message can click its components. Use invokerOnly for anything that acts on someone's behalf, or a permission guard; see Guards and middleware.
Stale components
A click on a component Nubis no longer knows — its id was renamed, its stored state expired, its state fails the schema — gets "This no longer works. It may be from an older message; try the command again." The id is what routes clicks, so changing it retires every copy already sent.