What are modals in Spraxium
Modals are overlay forms that Discord presents to users when triggered by a button click, select menu interaction, or application command. They collect structured input through fields like text inputs, select menus, radio groups, and checkboxes. In Spraxium, modals follow the same declarative decorator-handler pattern used by buttons and selects, but with a richer field system, built-in validation, dynamic fields, and per-user caching that warrants a dedicated documentation category.
The modal system consists of two main pieces: a @ModalComponent class that declares the modal's structure (title, fields, and options), and a @ModalHandler class that processes the submitted values. The ModalService renders the component into a Discord-ready modal object, and the ModalDispatcher routes submissions to the correct handler after running validation rules.
Unlike buttons and selects where the component is a lightweight decorator on a class, modals use property-level decorators to define each field. This gives you fine-grained control over field types, placeholder text, validation constraints, conditional inclusion, and dynamic generation, all expressed through TypeScript decorators.
When a modal is the right choice
Use a modal when the user needs to type or review multiple pieces of information before submitting. Feedback forms, ticket creation, profile setup, application flows, and admin input screens are the usual fit.
If the user only needs to pick one option or click a confirmation, a button or select menu is usually simpler. A modal is most helpful when the interaction needs structured text input and a clear submission boundary.
Defining a modal component
Use @ModalComponent to mark a class as a modal definition. The decorator accepts an object with id (the customId that Discord sends back on submission) and title (displayed at the top of the modal). Field decorators on the class properties define the modal's input fields.
import { ModalComponent, ModalInput } from '@spraxium/components';
@ModalComponent({ id: 'feedback-modal', title: 'Send Feedback' })
export class FeedbackModal {
@ModalInput({
label: 'Subject',
placeholder: 'Brief summary of your feedback',
required: true,
minLength: 3,
maxLength: 100,
style: 'short',
})
subject!: string;
@ModalInput({
label: 'Details',
placeholder: 'Describe your feedback in detail...',
required: true,
minLength: 10,
maxLength: 4000,
style: 'paragraph',
})
details!: string;
}Creating a modal handler
The @ModalHandler decorator links a handler class to a specific modal component. The handler's handle method receives the modal submission and any injected parameters. Use tagged field decorators to extract individual field values by their property name. The generic @ModalField(fieldId) works for every field type; the nine typed variants (@ModalTextField, @ModalStringSelectField, @ModalRadioGroupField, etc.) make the intent explicit and are preferred when the field type is known at compile time.
import { Ctx } from '@spraxium/common';
import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components';
import { FeedbackModal } from './feedback.modal';
@ModalHandler(FeedbackModal)
export class FeedbackModalHandler {
async handle(
@ModalTextField('subject') subject: string,
@ModalTextField('details') details: string,
@Ctx() ctx: ModalContext,
): Promise<void> {
await ctx.reply({
content: `Thank you for your feedback on '${subject}'.`,
flags: 'Ephemeral',
});
}
}For full documentation on all field decorators, guard integration, and the complete injection API, see Handlers and Field Injection.
Showing a modal
Use ModalService to build a Discord-ready modal and present it to the user. The build method takes the modal component class and returns a modal interaction response. You typically call this from a button handler, select handler, or slash command that has received an interaction with a showModal method.
import { ButtonHandler, Ctx } from '@spraxium/components';
import { ModalService } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import { OpenFeedbackButton } from './open-feedback.button';
import { FeedbackModal } from './feedback.modal';
@ButtonHandler(OpenFeedbackButton)
export class OpenFeedbackHandler {
constructor(private readonly modals: ModalService) {}
async handle(@Ctx() interaction: ButtonInteraction): Promise<void> {
const modal = this.modals.build(FeedbackModal);
await interaction.showModal(modal);
}
}Handler field injection
The @ModalHandler handler class receives submitted field values through parameter decorators. Use typed decorators like @ModalTextField, @ModalStringSelectField, and @ModalCheckboxField to inject each field value directly into the handle() method, with no manual interaction object parsing needed.
@ModalHandler(FeedbackModal)
export class FeedbackHandler {
async handle(
@ModalTextField('subject') subject: string,
@ModalTextField('message') message: string,
@Ctx() ctx: ModalContext,
): Promise<void> {
await ctx.reply({ content: `Received: ${subject}`, flags: 'Ephemeral' });
}
}The old @Field decorator is deprecated in the current API in favor of the typed variants. For the full list of typed field decorators and their resolved TypeScript types, see Handlers and Field Injection.
ModalService API reference
| Method | Signature | Purpose |
|---|---|---|
| build | build(modal: Type, data?: unknown) | Builds the modal from the component class. Pass data to supply dynamic field context. |
| buildFor | buildFor(modal: Type, interaction: Interaction, data?: unknown) | Builds with user context. Prefills cached field values if a previous submission failed validation. |
| storeCache | storeCache(modal: Type, interaction: ModalSubmitInteraction, values: Record<string, string>) | Manually stores field values in the cache. Used internally by validation flow. |
| clearCacheFor | clearCacheFor(modal: Type, interaction: ModalSubmitInteraction) | Removes cached values for a specific user and modal combination. |
| getCacheFor | getCacheFor(modal: Type, interaction: Interaction) | Retrieves cached field values. Returns null if no cache exists. |
The most common pair is build for a first render and buildFor for flows where you want user-aware prefilling after a validation failure. In other words, build is the simple path; buildFor is the ergonomic path for retry-heavy form flows.
Module registration
Register your modal handlers in the handlers array of a feature module. Modal component classes decorated with @ModalComponent and button classes decorated with @Button are metadata-only and are never registered in any module. The system scans all registered handler instances for @ModalHandler metadata during the boot lifecycle and reads the referenced modal component's metadata by class reference.
import { Module } from '@spraxium/core';
import { FeedbackModalHandler } from './feedback.handler';
import { OpenFeedbackHandler } from './open-feedback.handler';
@Module({
handlers: [FeedbackModalHandler, OpenFeedbackHandler],
})
export class FeedbackModule {}Dispatch flow
When a user submits a modal, the interaction goes through several stages before reaching your handler. Understanding this flow helps you reason about where validation, caching, and field resolution happen.
Interaction received
The Discord.js client emits an interactionCreate event with a ModalSubmitInteraction. The ComponentDispatcher checks if the customId matches a registered modal handler, splitting the custom ID at the tilde separator to extract the base ID and optional context ID.
Context resolution
If the customId contains a context ID (after the tilde), the dispatcher retrieves the SpraxiumContext from the storage adapter. If the context is expired or the user is restricted, the dispatcher sends an ephemeral reply and stops processing. If no context is needed, this step is skipped.
Validation execution
If the modal component has @ModalValidate rules, the ModalValidatorRunner executes them sequentially against the submitted field values. On the first failure, the runner caches all submitted values (if @ModalCache is present), sends the validation error embed, and stops processing. See the validation page for details.
Field resolution and handler invocation
The dispatcher resolves field parameter decorators (@ModalTextField, @ModalStringSelectField, etc.) by extracting values from the submission. The @Ctx decorator receives the raw ModalSubmitInteraction. If a @FlowContext decorator is present, the resolved context is injected. The handler's handle method is called with all resolved parameters.
This layered flow is what keeps modal handlers small. Validation, caching, context loading, and field extraction happen before your business logic runs, so the handler can focus on the accepted submission instead of reconstructing the form state by hand.