|
| 1 | +--- |
| 2 | +title: Handlers and Field Injection |
| 3 | +description: | |
| 4 | + Process modal submissions with @ModalHandler and inject field values directly into |
| 5 | + handle() parameters. Learn @ModalField, the ten typed field decorators, and @Ctx. |
| 6 | +order: 3 |
| 7 | +category: Modals |
| 8 | +--- |
| 9 | + |
| 10 | +## The handler class |
| 11 | + |
| 12 | +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. |
| 13 | + |
| 14 | +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. |
| 15 | + |
| 16 | +```typescript filename="src/modules/feedback/handlers/feedback.handler.ts" |
| 17 | +import { Ctx } from '@spraxium/common'; |
| 18 | +import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components'; |
| 19 | +import { FeedbackModal } from '../components/feedback.modal'; |
| 20 | + |
| 21 | +@ModalHandler(FeedbackModal) |
| 22 | +export class FeedbackHandler { |
| 23 | + async handle( |
| 24 | + @ModalTextField('subject') subject: string, |
| 25 | + @ModalTextField('message') message: string, |
| 26 | + @Ctx() ctx: ModalContext, |
| 27 | + ): Promise<void> { |
| 28 | + await ctx.reply({ |
| 29 | + content: `Thanks for your feedback on *${subject}*!`, |
| 30 | + flags: 'Ephemeral', |
| 31 | + }); |
| 32 | + } |
| 33 | +} |
| 34 | +``` |
| 35 | + |
| 36 | +`ModalContext` is exported from `@spraxium/components` and is a direct alias for `ModalSubmitInteraction`. Use whichever fits your team's import style; both are interchangeable. |
| 37 | + |
| 38 | +## The @ModalHandler decorator |
| 39 | + |
| 40 | +_@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. |
| 41 | + |
| 42 | +```typescript |
| 43 | +@ModalHandler(FeedbackModal) |
| 44 | +export class FeedbackHandler { |
| 45 | + async handle(...): Promise<void> { ... } |
| 46 | +} |
| 47 | +``` |
| 48 | + |
| 49 | +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`. |
| 50 | + |
| 51 | +## Parameter injection with @Ctx and field decorators |
| 52 | + |
| 53 | +Method parameters are resolved by the dispatcher at call time. Two categories of decorators control what each position receives. |
| 54 | + |
| 55 | +**@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. |
| 56 | + |
| 57 | +**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. |
| 58 | + |
| 59 | +```typescript filename="src/modules/profile/handlers/profile.handler.ts" |
| 60 | +import { Ctx } from '@spraxium/common'; |
| 61 | +import { |
| 62 | + ModalCheckboxField, |
| 63 | + ModalCheckboxGroupField, |
| 64 | + ModalHandler, |
| 65 | + ModalRadioGroupField, |
| 66 | + ModalStringSelectField, |
| 67 | + type ModalContext, |
| 68 | +} from '@spraxium/components'; |
| 69 | +import { ProfileModal } from '../components/profile.modal'; |
| 70 | + |
| 71 | +@ModalHandler(ProfileModal) |
| 72 | +export class ProfileHandler { |
| 73 | + async handle( |
| 74 | + @Ctx() ctx: ModalContext, |
| 75 | + @ModalStringSelectField('role') role: string, |
| 76 | + @ModalRadioGroupField('timezone') timezone: string | null, |
| 77 | + @ModalCheckboxGroupField('notifications') notifications: string[], |
| 78 | + @ModalCheckboxField('acceptedRules') acceptedRules: boolean, |
| 79 | + ): Promise<void> { |
| 80 | + if (!acceptedRules) { |
| 81 | + await ctx.reply({ content: '❌ You must accept the rules.', flags: 'Ephemeral' }); |
| 82 | + return; |
| 83 | + } |
| 84 | + await ctx.reply({ |
| 85 | + content: [ |
| 86 | + '✅ Profile saved!', |
| 87 | + `**Role:** ${role}`, |
| 88 | + `**Timezone:** ${timezone ?? 'Not set'}`, |
| 89 | + `**Notifications:** ${notifications.join(', ') || 'None'}`, |
| 90 | + ].join('\n'), |
| 91 | + flags: 'Ephemeral', |
| 92 | + }); |
| 93 | + } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +## Typed field decorators |
| 98 | + |
| 99 | +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_. |
| 100 | + |
| 101 | +<Table> |
| 102 | + <TableHead> |
| 103 | + <TableRow> |
| 104 | + <TableCell header>Decorator</TableCell> |
| 105 | + <TableCell header>Component field type</TableCell> |
| 106 | + <TableCell header>Resolved TypeScript type</TableCell> |
| 107 | + </TableRow> |
| 108 | + </TableHead> |
| 109 | + <TableBody> |
| 110 | + <TableRow> |
| 111 | + <TableCell>`@ModalTextField(fieldId)`</TableCell> |
| 112 | + <TableCell>*@ModalInput*</TableCell> |
| 113 | + <TableCell>`string`</TableCell> |
| 114 | + </TableRow> |
| 115 | + <TableRow> |
| 116 | + <TableCell>`@ModalStringSelectField(fieldId)`</TableCell> |
| 117 | + <TableCell>*@ModalSelect*</TableCell> |
| 118 | + <TableCell>`string | null` (single) or `string[]` (multi)</TableCell> |
| 119 | + </TableRow> |
| 120 | + <TableRow> |
| 121 | + <TableCell>`@ModalUserSelectField(fieldId)`</TableCell> |
| 122 | + <TableCell>*@ModalUserSelect*</TableCell> |
| 123 | + <TableCell>`User | null` (single) or `User[]` (multi)</TableCell> |
| 124 | + </TableRow> |
| 125 | + <TableRow> |
| 126 | + <TableCell>`@ModalRoleSelectField(fieldId)`</TableCell> |
| 127 | + <TableCell>*@ModalRoleSelect*</TableCell> |
| 128 | + <TableCell>`Role | null` (single) or `Role[]` (multi)</TableCell> |
| 129 | + </TableRow> |
| 130 | + <TableRow> |
| 131 | + <TableCell>`@ModalMentionableSelectField(fieldId)`</TableCell> |
| 132 | + <TableCell>*@ModalMentionableSelect*</TableCell> |
| 133 | + <TableCell>`User | Role | null`</TableCell> |
| 134 | + </TableRow> |
| 135 | + <TableRow> |
| 136 | + <TableCell>`@ModalChannelSelectField(fieldId)`</TableCell> |
| 137 | + <TableCell>*@ModalChannelSelect*</TableCell> |
| 138 | + <TableCell>`GuildBasedChannel | null` (single) or `GuildBasedChannel[]` (multi)</TableCell> |
| 139 | + </TableRow> |
| 140 | + <TableRow> |
| 141 | + <TableCell>`@ModalRadioGroupField(fieldId)`</TableCell> |
| 142 | + <TableCell>*@ModalRadioGroup*</TableCell> |
| 143 | + <TableCell>`string | null`</TableCell> |
| 144 | + </TableRow> |
| 145 | + <TableRow> |
| 146 | + <TableCell>`@ModalCheckboxGroupField(fieldId)`</TableCell> |
| 147 | + <TableCell>*@ModalCheckboxGroup*</TableCell> |
| 148 | + <TableCell>`string[]`</TableCell> |
| 149 | + </TableRow> |
| 150 | + <TableRow> |
| 151 | + <TableCell>`@ModalCheckboxField(fieldId)`</TableCell> |
| 152 | + <TableCell>*@ModalCheckbox*</TableCell> |
| 153 | + <TableCell>`boolean`</TableCell> |
| 154 | + </TableRow> |
| 155 | + <TableRow> |
| 156 | + <TableCell>`@ModalFileUploadField(fieldId)`</TableCell> |
| 157 | + <TableCell>*@ModalFileUpload*</TableCell> |
| 158 | + <TableCell>`Attachment[]`</TableCell> |
| 159 | + </TableRow> |
| 160 | + </TableBody> |
| 161 | +</Table> |
| 162 | + |
| 163 | +All typed decorators are imported from `@spraxium/components` alongside `ModalHandler`. |
| 164 | + |
| 165 | +<Callout tone="info" title="Single vs. multi-value selects"> |
| 166 | + For string, user, role, and channel select fields, whether the injected value |
| 167 | + is a single item or an array depends on the _maxValues_ configuration of the |
| 168 | + field in the component class. When _maxValues_ is greater than 1, the dispatcher |
| 169 | + returns an array; otherwise it returns a single value or `null`. |
| 170 | +</Callout> |
| 171 | + |
| 172 | +## Accessing raw fields via @Ctx |
| 173 | + |
| 174 | +For fields that don't 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. |
| 175 | + |
| 176 | +```typescript filename="src/modules/survey/handlers/survey.handler.ts" |
| 177 | +import { Ctx } from '@spraxium/common'; |
| 178 | +import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components'; |
| 179 | +import { SurveyModal } from '../components/survey.modal'; |
| 180 | + |
| 181 | +@ModalHandler(SurveyModal) |
| 182 | +export class SurveyHandler { |
| 183 | + async handle( |
| 184 | + @ModalTextField('feedback') feedback: string, |
| 185 | + @Ctx() ctx: ModalContext, |
| 186 | + ): Promise<void> { |
| 187 | + // Read radio group values directly from the interaction |
| 188 | + const rating = ctx.fields.getRadioGroup('rating') ?? 'N/A'; |
| 189 | + const tags = ctx.fields.getCheckboxGroup('tags'); |
| 190 | + |
| 191 | + await ctx.reply({ |
| 192 | + content: `Rating: ${rating}\nTags: ${tags.join(', ')}\nFeedback: ${feedback}`, |
| 193 | + flags: 'Ephemeral', |
| 194 | + }); |
| 195 | + } |
| 196 | +} |
| 197 | +``` |
| 198 | + |
| 199 | +## Combining DI services with field injection |
| 200 | + |
| 201 | +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. |
| 202 | + |
| 203 | +```typescript filename="src/modules/ticket/handlers/ticket-submit.handler.ts" |
| 204 | +import { Ctx } from '@spraxium/common'; |
| 205 | +import { ModalHandler, ModalTextField, type ModalContext } from '@spraxium/components'; |
| 206 | +import { Injectable } from '@spraxium/core'; |
| 207 | +import { TicketModal } from '../components/ticket.modal'; |
| 208 | +import { TicketService } from '../ticket.service'; |
| 209 | + |
| 210 | +@Injectable() |
| 211 | +@ModalHandler(TicketModal) |
| 212 | +export class TicketSubmitHandler { |
| 213 | + constructor(private readonly tickets: TicketService) {} |
| 214 | + |
| 215 | + async handle( |
| 216 | + @ModalTextField('subject') subject: string, |
| 217 | + @ModalTextField('description') description: string, |
| 218 | + @Ctx() ctx: ModalContext, |
| 219 | + ): Promise<void> { |
| 220 | + const ticket = await this.tickets.create({ |
| 221 | + userId: ctx.user.id, |
| 222 | + subject, |
| 223 | + description, |
| 224 | + }); |
| 225 | + |
| 226 | + await ctx.reply({ |
| 227 | + content: `✅ Ticket **#${ticket.id}** created!`, |
| 228 | + flags: 'Ephemeral', |
| 229 | + }); |
| 230 | + } |
| 231 | +} |
| 232 | +``` |
| 233 | + |
| 234 | +## Module registration |
| 235 | + |
| 236 | +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. |
| 237 | + |
| 238 | +```typescript filename="src/modules/feedback/feedback.module.ts" |
| 239 | +import { Module } from '@spraxium/core'; |
| 240 | +import { FeedbackHandler } from './handlers/feedback.handler'; |
| 241 | + |
| 242 | +@Module({ |
| 243 | + handlers: [FeedbackHandler], |
| 244 | +}) |
| 245 | +export class FeedbackModule {} |
| 246 | +``` |
| 247 | + |
| 248 | +<Callout tone="warning" title="Register the handler, not the component"> |
| 249 | + Only handler classes decorated with *@ModalHandler* go in the *handlers* |
| 250 | + array. Adding the modal component class to the module will have no effect and |
| 251 | + may produce confusing log output during boot. |
| 252 | +</Callout> |
| 253 | + |
| 254 | +## Next page |
| 255 | + |
| 256 | +Continue with [Dynamic Modals](/guide/modals-dynamics). |
0 commit comments