Spraxium logoSpraxium

Select Menus

Build all five Discord select menu types with static or dynamic options. Learn string, user, role, mentionable, and channel selects with handlers, dynamic option generation, and SelectService rendering.

Select menu types

Discord provides five select menu variants, and Spraxium supports all of them through dedicated decorators. String selects let you define custom options with labels and values. User, role, mentionable, and channel selects use Discord's built-in entity pickers that auto-populate with server data. All five types share the same handler pattern and parameter injection system.

Each select type is defined as a decorated class, linked to a handler via a type-specific handler decorator, and rendered through SelectService. The key difference between types is the decorator used and the kind of data the user selects. String selects require explicit option definitions, while the other four types populate their options from the server automatically.

TypeDecoratorHandlerOptions source
String@StringSelect@StringSelectHandler

Developer-defined via @SelectOption or dynamicOptions

User@UserSelect@UserSelectHandlerAuto-populated by Discord
Role@RoleSelect@RoleSelectHandlerAuto-populated by Discord
Mentionable@MentionableSelect@MentionableSelectHandlerAuto-populated by Discord (users + roles)
Channel@ChannelSelect@ChannelSelectHandler

Auto-populated by Discord with optional type filtering

String selects with static options

String selects are the most flexible type because you control the option list entirely. Define options using the stackable @SelectOption decorator applied to the component class. Each option requires a label (displayed text), a value (returned to the handler), and an optional description and emoji. Options stack bottom-to-top in decorator order.

import { StringSelect, SelectOption } from '@spraxium/components';

@StringSelect({
  customId: 'language_picker',
  placeholder: 'Choose a language',
  minValues: 1,
  maxValues: 1,
})
@SelectOption({ label: 'English', value: 'en', description: 'English (US)', emoji: '🇺🇸' })
@SelectOption({ label: 'Português', value: 'pt', description: 'Portuguese (BR)', emoji: '🇧🇷' })
@SelectOption({ label: 'Español', value: 'es', description: 'Spanish', emoji: '🇪🇸' })
export class LanguageSelect {}

Select configuration reference

The configuration object passed to any select decorator shares a common set of fields. The customId identifies the select for interaction routing, just like buttons. The placeholder, minValues, and maxValues fields control the select's UI behavior.

FieldTypeRequiredPurpose
customIdstringYesUnique identifier for interaction routing. Must be unique across the bot.
placeholderstringNoHint text shown when no option is selected.
minValuesnumberNoMinimum number of options the user must select. Defaults to 1.
maxValuesnumberNoMaximum number of options the user can select. Defaults to 1.
disabledbooleanNoWhen true, the select is rendered as non-interactive.
dynamicOptionsFunction returning SelectOptionConfig[]NoCallback that generates options at render time from context data. String selects only.

Dynamic options

When the option list depends on runtime data, use the dynamicOptions callback instead of static @SelectOption decorators. The callback receives the context data object passed to SelectService.build and must return an array of option objects. This pattern is common for selects populated from database queries, API responses, or user-specific data.

src/components/selects/category-select.component.ts
import { StringSelect } from '@spraxium/components';

@StringSelect({
  customId: 'category_picker',
  placeholder: 'Select a category',
  dynamicOptions: (data: { categories: Array<{ name: string; id: string }> }) =>
    data.categories.map((cat) => ({
      label: cat.name,
      value: cat.id,
    })),
})
export class CategorySelect {}

When rendering, pass the data object as the second argument to SelectService.build. The service calls the dynamicOptions callback with this data and uses the returned options to populate the select menu.

@SelectOption decorators and dynamicOptions are mutually exclusive. When both are present on the same class, the dynamic callback takes precedence and static options are ignored entirely.

Entity selects

User, role, mentionable, and channel selects are simpler because Discord populates their options automatically. You only need to configure the customId, optional constraints, and the handler. Channel selects additionally support a channelTypes filter to restrict which channel types appear.

import { UserSelect } from '@spraxium/components';

@UserSelect({
  customId: 'target_user',
  placeholder: 'Select a user',
  maxValues: 3,
})
export class TargetUserSelect {}

Handler parameter injection

Select handlers use the same parameter decorator system as button handlers, plus two select-specific decorators. The @Selected() decorator injects the full select menu interaction, giving you access to resolved users, roles, channels, or mentionables. The @SelectedValues() decorator injects just the string[] array from interaction.values, which is the most common pattern for string selects.

src/components/selects/role-select.handler.ts
import { RoleSelectHandler, Ctx, Selected } from '@spraxium/components';
import type { RoleSelectMenuInteraction } from 'discord.js';
import { AssignRoleSelect } from './role-select.component';

@RoleSelectHandler(AssignRoleSelect)
export class AssignRoleSelectHandler {
  async handle(
    @Ctx() interaction: RoleSelectMenuInteraction,
    @Selected() selected: RoleSelectMenuInteraction,
  ): Promise<void> {
    const roles = selected.roles;
    const names = roles.map((r) => r.name).join(', ');
    await interaction.reply({ content: `Assigning roles: ${names}`, flags: 'Ephemeral' });
  }
}

Rendering with SelectService

SelectService is the injectable service that builds select menu action rows from decorated classes. Its build method accepts the component class, an optional data argument for dynamicOptions callbacks, and an optional SpraxiumContext to embed a flow context ID in the menu's customId. The method is async because dynamic option callbacks may return promises.

src/commands/category.command-handler.ts
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { SelectService, ContextService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { CategorySelect } from '../components/selects/category-select.component';
import { CategoryCommand } from './category.command';

@SlashCommandHandler(CategoryCommand)
export class CategoryCommandHandler {
  constructor(
    private readonly selects: SelectService,
    private readonly context: ContextService,
  ) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const categories = await this.fetchCategories(interaction.guildId!);
    const ctx = await this.context.create({ userId: interaction.user.id });
    const row = await this.selects.build(CategorySelect, categories, ctx);

    await interaction.reply({
      content: 'Choose a category:',
      components: [row],
    });
  }

  private async fetchCategories(guildId: string) {
    return [
      { name: 'General', id: 'general' },
      { name: 'Support', id: 'support' },
      { name: 'Feedback', id: 'feedback' },
    ];
  }
}

Fluent builder alternative

For programmatic scenarios where defining a class is unnecessary overhead, SelectDefBuilder provides a fluent API for constructing select definitions inline. This builder is useful inside dynamic flows, temporary selects, or code-generated UIs where the decorator-based approach would create too many single-use classes.

src/commands/dynamic-select.command.ts
import { Selects } from '@spraxium/components';

const row = Selects.string('quick_pick')
  .placeholder('Quick selection')
  .minValues(1)
  .maxValues(1)
  .options([
    { label: 'Option A', value: 'a' },
    { label: 'Option B', value: 'b' },
  ])
  .buildRow();

The fluent builder produces the same discord.js action row as the decorator-based approach. However, it does not participate in the handler dispatch system because there is no class or metadata to register. Use it only for fire-and-forget selects where handler routing is not needed.

Dynamic selects

Static string selects assign options at definition time. @DynamicStringSelect breaks this constraint: you define the select once and generate the option list from live data at render time, such as database results, API responses, or any other runtime value. The payload system stores that dataset alongside the select, so the handler receives it back without a second database query.

Defining a dynamic string select

Decorate a class with @DynamicStringSelect and implement a static render method that receives the payload data and returns a SelectRenderConfig. The render method can be synchronous or async.

src/modules/topics/components/topic-select.component.ts
import { DynamicStringSelect } from '@spraxium/components';
import type { SelectRenderConfig } from '@spraxium/components';
import type { TopicsQuery } from '../topics.data';

@DynamicStringSelect({
  baseId: 'topic-pick',
  placeholder: 'Pick one or more topics',
  minValues: 1,
  maxValues: 3,
  payloadTtl: 600,
})
export class TopicSelect {
  static render(query: TopicsQuery): SelectRenderConfig {
    return {
      options: query.topics.map((t) => ({
        label: t.name,
        value: t.slug,
        description: t.description,
      })),
    };
  }
}

Rendering a dynamic string select

Call SelectService.buildDynamic with the component class and the payload. Spraxium stores the payload and embeds a reference to it in the select's customId.

src/modules/topics/handlers/topics-command.handler.ts
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { SelectService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { TopicsCommand } from '../commands/topics.command';
import { TopicSelect } from '../components/topic-select.component';
import type { TopicsQuery } from '../topics.data';

@SlashCommandHandler(TopicsCommand)
export class TopicsCommandHandler {
  constructor(private readonly selects: SelectService) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const query: TopicsQuery = {
      audience: 'developers',
      topics: await fetchTopics(),
    };

    const row = await this.selects.buildDynamic(TopicSelect, query);
    await interaction.reply({ content: 'Pick topics:', components: [row] });
  }
}

Handling the interaction

Use @DynamicSelectHandler to link a handler to a @DynamicStringSelect class. The @SelectPayload() decorator injects the payload stored during render. Use @SelectedValues() to inject interaction.values as a string[]. Both can appear alongside @Ctx() and @PayloadRef() in any order.

src/modules/topics/handlers/topic-select.handler.ts
import { Ctx } from '@spraxium/common';
import { DynamicSelectHandler, SelectPayload, SelectedValues } from '@spraxium/components';
import type { StringSelectMenuInteraction } from 'discord.js';
import { TopicSelect } from '../components/topic-select.component';
import type { TopicsQuery } from '../topics.data';

@DynamicSelectHandler(TopicSelect)
export class TopicSelectHandler {
  async handle(
    @Ctx() interaction: StringSelectMenuInteraction,
    @SelectedValues() picks: string[],
    @SelectPayload() query: TopicsQuery,
  ): Promise<void> {
    const picked = query.topics.filter((t) => picks.includes(t.slug));
    await interaction.reply({
      content: `Picked: ${picked.map((t) => t.name).join(', ')}`,
      flags: 'Ephemeral',
    });
  }
}

Dynamic select configuration reference

OptionTypeRequiredDescription
baseIdstringYesPrefix for generated custom IDs. Must be unique across all dynamic string selects in the application.
placeholderstringNoPlaceholder text shown when no option is selected.
minValuesnumberNoMinimum number of options the user must select. Defaults to 1.
maxValuesnumberNoMaximum number of options the user can select. Defaults to 1.
payloadTtlnumberNoSeconds until the stored payload expires. Defaults to 600. Only applies when encoding is store.
encodingstore or inlineNoStorage strategy for params. store (default) persists the full payload and embeds an opaque ref in the customId. inline encodes the params field from SelectRenderConfig directly into the customId. Use inline for small primitive data only — the 100-character Discord custom ID limit applies.

Inline parameter encoding

By default, @DynamicStringSelect persists the full payload in a TTL-bounded store and embeds only an opaque reference in the custom ID. For small, primitive data — like a source tag, a mode flag, or a single record ID — you can use encoding: 'inline' instead. The framework encodes the params field returned by render() directly into the custom ID string, eliminating any dependency on the payload store and the TTL entirely. The data lives in the Discord message for as long as the message exists.

The Discord custom ID limit of 100 characters applies to inline-encoded selects. The framework throws DynamicSelectInlinePayloadTooLargeError at render time if the encoded string exceeds the limit. Keep inline params small: one or two short values. Use the default store encoding for anything larger.

import { DynamicStringSelect } from '@spraxium/components';
import type { SelectRenderConfig } from '@spraxium/components';

interface Topic {
  slug: string;
  name: string;
}

@DynamicStringSelect({
  baseId: 'topic-filter',
  placeholder: 'Filter by topic',
  encoding: 'inline',
})
export class FilterTopicSelect {
  static render(data: { topics: Topic[]; audience: string }): SelectRenderConfig {
    return {
      options: data.topics.map((t) => ({ label: t.name, value: t.slug })),
      params: { audience: data.audience }, // travels inside the customId
    };
  }
}

Note that @SelectParams() replaces @SelectPayload() for inline-encoded selects. Using @SelectPayload() or @PayloadRef() in an inline handler causes a bootstrap error. The params type is inferred from the params field returned by render(), so TypeScript knows the shape without any manual casting.

Inline-encoded selects are rendered with SelectService.build, not buildDynamic. The service detects the encoding: 'inline' flag automatically and routes to the inline path.

Module registration

Only the handler class needs to be registered, in the handlers array of a feature module. This applies to all five select types identically. Select component classes decorated with @StringSelect, @UserSelect, or similar decorators are metadata-only and are never added to any module. The dispatcher reads their metadata from the class reference stored inside each handler's decorator.

src/modules/settings/settings.module.ts
import { Module } from '@spraxium/common';
import { LanguageSelectHandler } from './components/language-select.handler';
import { TargetUserSelectHandler } from './components/user-select.handler';

@Module({
  handlers: [LanguageSelectHandler, TargetUserSelectHandler],
})
export class SettingsModule {}