Skip to main content

State Management

FlowCord provides two distinct state containers, each with a different scope and lifetime. Understanding which to use — and when — is key to building menus that behave correctly.

StateAccessor<TState> is scoped to the current menu instance. It is created fresh each time the menu opens and is discarded when the menu is left (unless state preservation is configured).

type CounterState = { count: number; lastAction: string };

new MenuBuilder<CounterState>(session, 'counter')
.setup((ctx) => {
// Initialize state here — ctx.state starts as an empty object
ctx.state.set('count', 0);
ctx.state.set('lastAction', 'none');
})
.setEmbeds((ctx) => [
new EmbedBuilder()
.setTitle(`Count: ${ctx.state.get('count')}`)
.setDescription(`Last action: ${ctx.state.get('lastAction')}`),
])
.setButtons(() => [
{
label: 'Increment',
action: async (ctx) => {
ctx.state.set('count', ctx.state.get('count') + 1);
ctx.state.set('lastAction', 'increment');
},
},
])

API

ctx.state.get('key')               // Get a typed value
ctx.state.set('key', value) // Set a typed value
ctx.state.merge({ key: value }) // Shallow-merge a partial state object
ctx.state.reset(newState) // Replace the entire state object
ctx.state.current // Readonly snapshot of the full state

Initialization

ctx.state starts as an empty object {}. The setup() hook is the right place to initialize it — setup() runs once when the menu is first created, before any rendering.

Do not rely on initial values being present in setEmbeds() or setButtons() without first setting them in setup().

State on navigation

By default, menu state is recreated from scratch each time the menu opens — whether it's being opened for the first time or returned to via goBack(). setup() runs again and state starts empty.

If you want state to be preserved when the user navigates away and returns, call .setPreserveStateOnReturn() on the builder. FlowCord will snapshot the state and pagination position when the user leaves, and restore them when goBack() returns to the menu — skipping setup() entirely.

new MenuBuilder<BrowseState>(session, 'browse')
.setPreserveStateOnReturn() // State and pagination restored on goBack()
.setTrackedInHistory()
.setup((ctx) => {
ctx.state.set('filter', 'all');
ctx.state.set('sortBy', 'name');
})

Session state — ctx.sessionState

StateStore<TSessionState> is scoped to the entire session — shared across all menus and persists until the session ends. Use it for data that needs to travel between menus.

type SessionState = { selectedItemId: string | null; cart: string[] };

new MenuBuilder<{}, SessionState>(session, 'shop')
.setup((ctx) => {
if (!ctx.sessionState.has('cart')) {
ctx.sessionState.set('cart', []);
}
})
.setButtons((ctx) => [
{
label: 'Add to Cart',
action: async (ctx) => {
const cart = ctx.sessionState.get('cart') ?? [];
ctx.sessionState.set('cart', [...cart, ctx.sessionState.get('selectedItemId')]);
},
},
{
label: 'View Cart',
action: goTo('cart'),
// 'cart' menu can read ctx.sessionState.get('cart') directly
},
])

API

ctx.sessionState.get('key')          // Get a value (undefined if not set)
ctx.sessionState.set('key', value) // Set a value
ctx.sessionState.has('key') // Check if a key exists
ctx.sessionState.delete('key') // Remove a key (returns true if it existed)
ctx.sessionState.clear() // Remove all keys
ctx.sessionState.keys() // IterableIterator<string> of all keys
ctx.sessionState.size // Number of stored entries

Choosing between them

ctx.statectx.sessionState
ScopeCurrent menu instanceEntire session
LifetimeReset on navigation (unless preserved)Lives until session ends
TypingTState generic on MenuBuilderTSessionState generic on MenuBuilder
Best used forUI state local to this menuData that crosses menu boundaries

A common pattern: initialize session state in the first menu's setup(), then read and update it from any subsequent menu.

External data and caching

sessionState is for inter-menu context — passing data between menus in the same flow. It is not a DB cache.

For DB-backed data, querying directly in render callbacks or action handlers is the simplest approach. An external cache (such as node-cache) is worth adding when the same data is read frequently across multiple sessions, or when you need explicit invalidation as records change — this is the same pattern discord.js recommends for autocomplete handlers.

Keep sessionState and your cache layer separate:

  • sessionState — ephemeral, session-scoped inter-menu context
  • External cache — cross-session, explicitly invalidatable, backed by your DB

Sessions are in-memory and do not persist across bot restarts — design your session state accordingly.