What is flow context
Flow context is a stateful data container that threads information across sequential component interactions. When a user clicks a button, selects an option, and then submits a modal, each handler in that sequence may need access to data from previous steps. Without flow context, you would need to encode state into customId strings (limited to 100 characters), store data in an external database, or use global maps that leak memory.
SpraxiumContext solves this by providing a typed, TTL-bound, optionally user-restricted data container that is stored in a pluggable adapter and referenced by a UUID appended to the component's customId. The ContextService creates contexts, the component services attach context IDs to rendered components, and the dispatcher resolves contexts automatically before invoking handlers.
The flow context system is shared across all component types. A context created for a button row can be read by a modal handler triggered later in the same flow. This cross-component threading is what makes multi-step interactive experiences possible without manual state management.
Creating a context
Inject ContextService into your command or service and call create with the data object you want to thread through the flow. The method returns a SpraxiumContext with a generated UUID, the data, timestamps, and the TTL. You then pass this context to the component service's build method.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { ContextService, ButtonService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { NextStepButton } from '../components/next-step.button';
import { SetupWizardCommand } from './setup-wizard.command';
@SlashCommandHandler(SetupWizardCommand)
export class SetupWizardCommandHandler {
constructor(
private readonly context: ContextService,
private readonly buttons: ButtonService,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const ctx = await this.context.create(
{ step: 1, guildId: interaction.guildId, settings: {} },
{ ttl: 600, restrictedTo: interaction.user.id },
);
const row = this.buttons.rowWithContext(ctx, NextStepButton);
await interaction.reply({
content: 'Welcome to the setup wizard. Click Next to begin.',
components: [row],
});
}
}SpraxiumContext interface
The SpraxiumContext object carries both your custom data and operational metadata. The id is a UUID that gets appended to customId strings via the tilde separator. The data field holds your typed payload. The ttl and expiresAt fields control automatic expiration, and restrictedTo limits which user can interact with components carrying this context.
| Field | Type | Purpose |
|---|---|---|
| id | string | UUID generated by the runtime UUID function. Appended to customId via tilde separator. |
| data | T | Your typed payload. Can be any serializable object. Mutable through update. |
| createdAt | number | Unix timestamp in milliseconds when the context was created. |
| ttl | number | Time-to-live in seconds. Used to compute expiresAt. |
| expiresAt | number | Unix timestamp in milliseconds when the context expires. Computed as createdAt + ttl * 1000. |
| restrictedTo | string | undefined | When set, only this user ID can interact with components carrying this context. |
Reading context in handlers
Use the @FlowContext() parameter decorator in your handler's handle method to receive the resolved context. The dispatcher extracts the context ID from the customId, retrieves the context from the storage adapter, validates expiration and user restrictions, and injects the resolved object. If the context has expired or the user is restricted, the dispatcher replies with the configured error message and does not invoke the handler.
import { ButtonHandler, Ctx, FlowContext } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import type { SpraxiumContext } from '@spraxium/components';
import { NextStepButton } from './next-step.button';
interface WizardData {
step: number;
guildId: string;
settings: Record<string, unknown>;
}
@ButtonHandler(NextStepButton)
export class NextStepButtonHandler {
async handle(
@Ctx() interaction: ButtonInteraction,
@FlowContext() ctx: SpraxiumContext<WizardData>,
): Promise<void> {
const { step, settings } = ctx.data;
await interaction.reply({
content: `You are on step ${step}. Current settings: ${JSON.stringify(settings)}`,
flags: 'Ephemeral',
});
}
}Updating context
The ContextService.update method patches the context data without replacing the entire object. This is useful in multi-step flows where each handler adds or modifies a subset of the data. The method returns a boolean indicating whether the update succeeded (context found and not expired).
import { ButtonHandler, Ctx, FlowContext } from '@spraxium/components';
import { ContextService } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import type { SpraxiumContext } from '@spraxium/components';
import { StepTwoButton } from './step-two.button';
interface WizardData {
step: number;
guildId: string;
settings: Record<string, unknown>;
}
@ButtonHandler(StepTwoButton)
export class StepTwoButtonHandler {
constructor(private readonly context: ContextService) {}
async handle(
@Ctx() interaction: ButtonInteraction,
@FlowContext() ctx: SpraxiumContext<WizardData>,
): Promise<void> {
await this.context.update<WizardData>(ctx.id, {
step: 2,
settings: { ...ctx.data.settings, language: 'en' },
});
await interaction.reply({
content: 'Step 2 complete. Proceeding to step 3.',
flags: 'Ephemeral',
});
}
}Deleting context
Call ContextService.delete when the flow is complete or cancelled. This immediately removes the context from the storage adapter. While contexts expire automatically based on TTL, explicit deletion is good practice for flows that complete normally, as it frees storage and prevents stale data from being read in edge cases.
import { ButtonHandler, Ctx, FlowContext } from '@spraxium/components';
import { ContextService } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import type { SpraxiumContext } from '@spraxium/components';
import { FinishButton } from './finish.button';
@ButtonHandler(FinishButton)
export class FinishWizardHandler {
constructor(private readonly context: ContextService) {}
async handle(
@Ctx() interaction: ButtonInteraction,
@FlowContext() ctx: SpraxiumContext<unknown>,
): Promise<void> {
await this.context.delete(ctx.id);
await interaction.reply({ content: 'Setup complete!', flags: 'Ephemeral' });
}
}Deleting context from outside a handler
ContextService.delete accepts the context ID as a plain string, so it can be called from any service, command handler, or listener - not only from inside a button or modal handler that receives a @FlowContext() injection.
The key is to persist the context ID alongside your own application data when the context is created:
@SlashCommandHandler(StartOnboardingCommand)
export class StartOnboardingCommandHandler {
constructor(
private readonly context: ContextService,
private readonly buttons: ButtonService,
private readonly db: OnboardingRepository,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const ctx = await this.context.create(
{ guildId: interaction.guildId!, step: 1 },
{ ttl: 86400, restrictedTo: interaction.user.id },
);
// Store ctx.id in your database so it can be cancelled from outside
await this.db.createSession(interaction.user.id, { contextId: ctx.id });
const row = this.buttons.rowWithContext(ctx, NextStepButton);
await interaction.reply({ content: 'Onboarding started!', components: [row] });
}
}Persisting a recoverable context session record
For long-running flows, store a dedicated session row so you can recover contextId later:
interface OnboardingSessionRecord {
userId: string;
contextId: string;
onboardingMessageId: string;
state: 'active' | 'cancelled' | 'completed';
}
export class OnboardingRepository {
async createSession(
userId: string,
data: Omit<OnboardingSessionRecord, 'userId'>,
): Promise<void> {
// INSERT INTO onboarding_sessions (...)
}
async findActiveSession(userId: string): Promise<OnboardingSessionRecord | null> {
// SELECT ... WHERE user_id = ? AND state = 'active'
return null;
}
}Then cancel the flow from an admin command or any other service without needing the context in scope:
@SlashCommandHandler(CancelOnboardingCommand)
export class CancelOnboardingCommandHandler {
constructor(
private readonly context: ContextService,
private readonly db: OnboardingRepository,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const session = await this.db.findActiveSession(interaction.options.getUser('user', true).id);
if (session?.contextId) {
// Delete the context - any pending buttons in the flow
// will now receive the expired message when clicked.
await this.context.delete(session.contextId);
}
await this.db.removeSession(session.userId);
await interaction.reply({ content: 'Onboarding session cancelled.', flags: 'Ephemeral' });
}
}Storage adapters
The context system uses a pluggable storage adapter configured through the defineComponents plugin. Four built-in adapters cover different deployment scenarios. All adapters implement the ContextStorageAdapter interface with async get, set, delete, and entries methods.
| Adapter | Config value | Persistence | Best for |
|---|---|---|---|
| Memory | memory | Volatile, lost on restart | Development and testing. Zero configuration required. |
| Filesystem | filesystem | JSON file in the .spraxium directory | Single-instance bots that need contexts to survive restarts. |
| Redis | redis | Redis keys with the spraxium:ctx: prefix | Multi-instance deployments with shared state requirements. |
| SQLite | sqlite | SQLite database at .spraxium/contexts.db | Lightweight persistence without external infrastructure. |
import { defineComponents } from '@spraxium/components';
export const componentsPlugin = defineComponents({
context: {
storage: 'redis',
defaultTtl: 600,
},
});ContextService API reference
| Method | Signature | Purpose |
|---|---|---|
| create | create<T>(data: T, options?): Promise<SpraxiumContext<T>> | Creates a new context with a generated UUID, stores it, and returns the full object. |
| get | get<T>(id: string): Promise<SpraxiumContext<T> | null> | Retrieves a context by ID. Returns null if not found or expired. |
| update | update<T>(id: string, patch: Partial<T>): Promise<boolean> | Merges a partial data object into the existing context data. Returns false if not found. |
| delete | delete(id: string): Promise<void> | Immediately removes the context from storage. Resolves silently if not found. |
TTL and expiration behavior
Every context has a TTL measured in seconds. When a user interacts with a component whose context has expired, the dispatcher replies with the configured errorMessages.expired message and does not invoke the handler. Expired contexts are not deleted immediately from storage; they are cleaned up lazily when accessed or during periodic sweeps by the storage adapter.
The default TTL is set in the defineComponents configuration. Individual contexts can override it through the options.ttl parameter in ContextService.create.
For short interactions like confirmation dialogs, a TTL of 60 to 120 seconds is appropriate. For multi-step wizards, 600 to 1800 seconds provides a comfortable window for users to complete the flow without hitting an expiration mid-interaction.
User restrictions
The restrictedTo option limits context access to a specific user ID. When set, only interactions from that user ID are dispatched to the handler. Other users who click the component receive the configured errorMessages.restricted message. This is essential for flows where the component is visible to everyone in a channel but should only be actionable by the user who initiated the command.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { ContextService, ButtonService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { VerifyButton } from '../components/verify.button';
import { VerificationCommand } from './verification.command';
@SlashCommandHandler(VerificationCommand)
export class VerificationCommandHandler {
constructor(
private readonly context: ContextService,
private readonly buttons: ButtonService,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const ctx = await this.context.create(
{ userId: interaction.user.id },
{ ttl: 300, restrictedTo: interaction.user.id },
);
const row = this.buttons.rowWithContext(ctx, VerifyButton);
await interaction.reply({
content: 'Click the button to verify your identity.',
components: [row],
});
}
}