Skip to main content

Lifecycle Hooks

FlowCord exposes hooks at every meaningful stage of a menu session. All hooks receive ctx as their only argument and can be sync or async — they are awaited sequentially.

Execution order

Menu opens


setup() ← One-time initialization. Runs before onEnter.


onEnter ← Menu entered. Runs on first open and on every return via goBack().


┌─── RENDER LOOP ────────────────────────────────────┐
│ │ │
│ ▼ │
│ beforeRender ← Before render callbacks run │
│ │ │
│ (setEmbeds / setButtons / setLayout run) │
│ (Discord message sent or updated) │
│ │ │
│ ▼ │
│ afterRender ← After message is sent │
│ │ │
│ (Await user interaction) │
│ │ │
│ ▼ │
│ onAction ← Before the action callback runs │
│ │ │
│ (Action callback executes) │
│ │ │
│ onNext / onPrevious ← On pagination │
│ │ │
│ (No navigation → loop back to beforeRender) │
└────────────────────────────────────────────────────┘

▼ (navigation or close)
onCancel ← If user pressed Cancel (fires before onLeave)


onLeave ← Menu being exited

Hook reference

setup(ctx)

Runs once per menu instance creation, before onEnter. Use it to initialize ctx.state — state starts as an empty object and setup() is the only guaranteed initialization point.

When you navigate away and then goBack() to a menu without .setPreserveStateOnReturn(), the menu factory re-runs and a new instance is created — setup() fires again. With .setPreserveStateOnReturn(), the existing instance is reused and setup() is skipped; state is restored from snapshot instead.

.setup((ctx) => {
ctx.state.set('page', 1);
ctx.state.set('filter', 'all');

// Initialize session state if this is the first menu in the flow
if (!ctx.sessionState.has('userId')) {
ctx.sessionState.set('userId', ctx.interaction.user.id);
}
})

:::note
`setup()` is defined via `.setup(fn)` on the builder, not `.onEnter()`. Unlike the other hooks, it doesn't fire on every entry — only on creation.
:::

### `onEnter(ctx)`

Runs every time the menu is entered — on first open, and again when `goBack()` returns to it (including after a `setPreserveStateOnReturn()` restore). Good for data that should refresh on each visit.

```ts
.onEnter(async (ctx) => {
// Refresh data from cache on every visit
const profile = await cache.get(`profile:${ctx.interaction.user.id}`);
ctx.state.set('profile', profile);
})

beforeRender(ctx)

Runs at the start of each render cycle, before setEmbeds() / setButtons() / setLayout() are called. Use it to compute or transform state that the render callbacks will read.

.beforeRender((ctx) => {
const items = ctx.state.get('allItems') ?? [];
const filtered = items.filter(i => i.active);
ctx.state.set('visibleItems', filtered);
})

afterRender(ctx)

Runs after the Discord message has been sent or updated. Suitable for side effects that should happen after the user can see the new state — analytics, logging, external notifications.

.afterRender(async (ctx) => {
await analytics.track('menu_rendered', {
menu: ctx.menu.name,
userId: ctx.interaction.user.id,
});
})

Avoid mutating ctx.state here — the message has already been rendered and changes won't be visible until the next cycle.

onAction(ctx)

Fires before the button or select action callback executes, on every user interaction. Useful for cross-cutting concerns like audit logging or rate limiting.

.onAction(async (ctx) => {
await auditLog.record({
user: ctx.interaction.user.id,
menu: ctx.menu.name,
action: ctx.interaction.isMessageComponent() ? ctx.interaction.customId : 'unknown',
});
})

onNext(ctx) / onPrevious(ctx)

Fire when the user clicks the Next or Previous pagination button. Receive the same ctx as other hooks, with ctx.pagination reflecting the updated page.

.onNext((ctx) => {
console.log(`Moved to page ${ctx.pagination?.currentPage}`);
})

onLeave(ctx)

Runs when the menu is being exited — via goTo(), goBack(), or close(). Use it for cleanup or to persist any final state.

.onLeave(async (ctx) => {
// Persist unsaved draft before leaving
if (ctx.state.get('isDirty')) {
await db.saveDraft(ctx.state.get('form'));
}
})

onCancel(ctx)

Runs when the user presses the Cancel button (added via .setCancellable()), immediately before onLeave. Use it to distinguish cancellation from a normal exit.

.onCancel(async (ctx) => {
await db.deleteDraft(ctx.interaction.user.id);
})

Global hooks

Register a hook that fires for every menu in every session. Useful for logging, analytics, or access control that applies across your entire bot.

flowcord.engine.hookRegistry.register('onEnter', async (ctx) => {
console.log(`[${ctx.session.id}] Entered: ${ctx.menu.name}`);
});

flowcord.engine.hookRegistry.register('onAction', async (ctx) => {
metrics.increment('menu.action');
});

Global hooks fire before the menu-specific hook of the same name. All hooks are awaited sequentially — a slow global hook will delay the menu-specific one.

The available hook names are: onEnter, onLeave, onCancel, beforeRender, afterRender, onAction, onNext, onPrevious.

note

setup() cannot be registered as a global hook — it is always menu-specific.