The handler class
A handler class is a standalone class decorated with @ModalHandler that contains the submission logic for a modal. The decorator receives the modal component class as its argument, creating a metadata link between the component schema and the submission logic. The handler class is instantiated by the DI container during module loading, so constructor parameters are resolved automatically from the module providers.
The handler must expose a handle method. When a modal submission arrives whose customId matches the component's id, the ModalDispatcher calls this method after the guard pipeline passes. Method parameters are populated from metadata: @Ctx() injects the raw ModalSubmitInteraction, and field decorators inject the resolved value for each named field.
import { Ctx } from '@spraxium/common';
import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components';
import { FeedbackModal } from '../components/feedback.modal';
@ModalHandler(FeedbackModal)
export class FeedbackHandler {
async handle(
@ModalTextField('subject') subject: string,
@ModalTextField('message') message: string,
@Ctx() ctx: ModalContext,
): Promise<void> {
await ctx.reply({
content: `Thanks for your feedback on *${subject}*!`,
flags: 'Ephemeral',
});
}
}ModalContext is exported from @spraxium/components and is a direct alias for ModalSubmitInteraction. Use whichever fits your team's import style; both are interchangeable.
The @ModalHandler decorator
@ModalHandler accepts the modal component class as its only argument. There is no routing configuration; each handler owns exactly one component, identified by the component's id string.
@ModalHandler(FeedbackModal)
export class FeedbackHandler {
async handle(...): Promise<void> { ... }
}Spraxium reads the id from the @ModalComponent decorator on FeedbackModal and registers this handler under that id. When Discord sends a MODAL_SUBMIT interaction, the dispatcher matches customId to the registered id and calls handle.
Parameter injection with @Ctx and field decorators
Method parameters are resolved by the dispatcher at call time. Two categories of decorators control what each position receives.
@Ctx() injects the raw ModalSubmitInteraction (aliased as ModalContext). It can appear at any position and is the entry point to the full Discord API surface for the interaction: reply, deferReply, followUp, and the raw fields manager.
Field decorators inject the submitted value for a specific field, identified by the property name defined in the modal component class. The generic @ModalField(fieldId) works for any field type. The typed variants, documented in the next section, carry the intent of the field type explicitly and should be preferred when the field type is known at compile time.
In practice, that means most handlers only need two things: the resolved field values and the interaction object for replying. Once those are injected, the method body can stay focused on persistence, service calls, and response flow.
import { Ctx } from '@spraxium/common';
import {
ModalCheckboxField,
ModalCheckboxGroupField,
ModalHandler,
ModalRadioGroupField,
ModalStringSelectField,
type ModalContext,
} from '@spraxium/components';
import { ProfileModal } from '../components/profile.modal';
@ModalHandler(ProfileModal)
export class ProfileHandler {
async handle(
@Ctx() ctx: ModalContext,
@ModalStringSelectField('role') role: string,
@ModalRadioGroupField('timezone') timezone: string | null,
@ModalCheckboxGroupField('notifications') notifications: string[],
@ModalCheckboxField('acceptedRules') acceptedRules: boolean,
): Promise<void> {
if (!acceptedRules) {
await ctx.reply({ content: '⌠You must accept the rules.', flags: 'Ephemeral' });
return;
}
await ctx.reply({
content: [
'✅ Profile saved!',
`**Role:** ${role}`,
`**Timezone:** ${timezone ?? 'Not set'}`,
`**Notifications:** ${notifications.join(', ') || 'None'}`,
].join('\n'),
flags: 'Ephemeral',
});
}
}Typed field decorators
Ten typed parameter decorators are provided, each corresponding to a specific field type in the modal component class. Using them makes the intended field type explicit in handler code without any runtime overhead; all typed decorators store the same { index, fieldId } metadata as the generic @ModalField.
| Decorator | Component field type | Resolved TypeScript type |
|---|---|---|
| @ModalTextField(fieldId) | @ModalInput | string |
| @ModalStringSelectField(fieldId) | @ModalSelect | string | null (single) or string[] (multi) |
| @ModalUserSelectField(fieldId) | @ModalUserSelect | User | null (single) or User[] (multi) |
| @ModalRoleSelectField(fieldId) | @ModalRoleSelect | Role | null (single) or Role[] (multi) |
| @ModalMentionableSelectField(fieldId) | @ModalMentionableSelect | User | Role | null |
| @ModalChannelSelectField(fieldId) | @ModalChannelSelect | GuildBasedChannel | null (single) or GuildBasedChannel[] (multi) |
| @ModalRadioGroupField(fieldId) | @ModalRadioGroup | string | null |
| @ModalCheckboxGroupField(fieldId) | @ModalCheckboxGroup | string[] |
| @ModalCheckboxField(fieldId) | @ModalCheckbox | boolean |
| @ModalFileUploadField(fieldId) | @ModalFileUpload | Attachment[] |
All typed decorators are imported from @spraxium/components alongside ModalHandler.
Choose the typed decorator whenever the field type is already known from the component schema. It communicates intent immediately in code review and saves the next reader from mentally mapping a generic @ModalField call back to the component definition.
Single vs. multi-value selects
For string, user, role, and channel select fields, whether the injected value is a single item or an array depends on the maxValues configuration of the field in the component class. When maxValues is greater than 1, the dispatcher returns an array; otherwise it returns a single value or null.
Accessing raw fields via @Ctx
For fields that do not have a corresponding field decorator, such as radio_group fields accessed outside of injection or fields read conditionally, use @Ctx() and call the appropriate method on ctx.fields directly.
import { Ctx } from '@spraxium/common';
import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components';
import { SurveyModal } from '../components/survey.modal';
@ModalHandler(SurveyModal)
export class SurveyHandler {
async handle(
@ModalTextField('feedback') feedback: string,
@Ctx() ctx: ModalContext,
): Promise<void> {
// Read radio group values directly from the interaction
const rating = ctx.fields.getRadioGroup('rating') ?? 'N/A';
const tags = ctx.fields.getCheckboxGroup('tags');
await ctx.reply({
content: `Rating: ${rating}\nTags: ${tags.join(', ')}\nFeedback: ${feedback}`,
flags: 'Ephemeral',
});
}
}Combining DI services with field injection
Because handler classes are instantiated through the DI container, any module provider can be injected through the constructor. This is the standard pattern for accessing databases, external APIs, or application services from inside a handler.
import { Ctx } from '@spraxium/common';
import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components';
import { Injectable } from '@spraxium/core';
import { TicketModal } from '../components/ticket.modal';
import { TicketService } from '../ticket.service';
@Injectable()
@ModalHandler(TicketModal)
export class TicketSubmitHandler {
constructor(private readonly tickets: TicketService) {}
async handle(
@ModalTextField('subject') subject: string,
@ModalTextField('description') description: string,
@Ctx() ctx: ModalContext,
): Promise<void> {
const ticket = await this.tickets.create({
userId: ctx.user.id,
subject,
description,
});
await ctx.reply({
content: `✅ Ticket **#${ticket.id}** created!`,
flags: 'Ephemeral',
});
}
}Module registration
Register handler classes in the handlers array of a feature module. Modal component classes decorated with @ModalComponent are metadata-only and are never registered; Spraxium resolves them transitively from the handler's @ModalHandler link.
import { Module } from '@spraxium/core';
import { FeedbackHandler } from './handlers/feedback.handler';
@Module({
handlers: [FeedbackHandler],
})
export class FeedbackModule {}Register the handler, not the component
Only handler classes decorated with @ModalHandler go in the handlers array. Adding the modal component class to the module will have no effect and may produce confusing log output during boot.