Building Multi-Step Slack Modals: The View Stack, State, and Not Losing Input
Master views.push, views.update, and private_metadata to build branching multi-step Slack modal wizards for ops forms that carry state and never reset a half-filled form.
- #slack
- #ai
- #modals
- #views
- #block-kit
Our access-request modal started as one screen and grew into a monster. Step one picked the system, step two picked the access level — and which levels were valid depended on the system — and step three was a justification box that only appeared for production access. The first version pushed a new view at every step, and the moment a user clicked “Back” to change their system choice, every value they’d entered downstream vanished. People hated it. The fix wasn’t more code; it was understanding how Slack’s view stack actually behaves, which the docs describe in fragments scattered across five pages.
This is a guide to building multi-step modal flows in Slack that branch, allow going back, and never lose a user’s input. The mechanics are small — views.open, views.push, views.update, private_metadata, and response_action — but composing them correctly is the difference between a wizard people tolerate and one they curse. Ops forms are exactly where this matters, because the data feeds real automation.
The view stack and its hard limit
A modal lives on a stack. views.open puts the root view on it. views.push stacks a new view on top, giving the user a “Back” button to pop it. views.update replaces the current top view in place — no new stack entry, no Back button created. And the stack has a hard limit of three views deep. Try to push a fourth and Slack rejects it.
That limit shapes the whole design. A five-step wizard cannot be five pushed views. The pattern that works is to use views.update to advance most steps in place — replacing the current view’s blocks with the next step’s — and reserve views.push for genuine branches where Back-to-the-previous-step is the desired affordance. If you’re building ops tooling with modals, internalize the three-view ceiling early; it’s the constraint that dictates your navigation strategy.
State does not flow downhill by itself
Here’s the trap that ate our access-request form: a pushed or updated view does not automatically carry the values from the previous view. When you push view two, the user’s selections from view one are not in view two’s state. When you views.update to step three, you’re replacing the blocks, and anything you don’t re-render is gone from the screen and from view.state.values.
So you need a deliberate place to keep accumulated state. The two real options:
private_metadata— a string attached to the view, capped around 3000 characters. Serialize accumulated state as JSON and pass it along on every push/update. This is the durable choice.- Reconstructing from
view.state.values— only works for views still on the stack, and only for fields currently rendered. Fragile for anything multi-step.
Use private_metadata. On each transition, read the prior metadata, merge in what the user just entered, and write it back:
app.view('step1_submit', async ({ ack, view, body }) => {
const prior = JSON.parse(view.private_metadata || '{}');
const state = { ...prior, system: view.state.values.sys_block.sys_input.selected_option.value };
await ack({
response_action: 'update', // advance in place, no new stack entry
view: buildStep2View({
metadata: JSON.stringify(state), // carry state forward — this is the key move
validLevels: levelsFor(state.system),
}),
});
});
The JSON.stringify(state) going into the next view’s private_metadata is the load-bearing line. Drop it and the wizard forgets everything between steps.
Branching on a prior choice
Branching is just deciding which view to render next based on accumulated state. Because step two knows the chosen system (it’s in the metadata), it can render only the valid access levels. And because step three is conditional, the step-two handler decides whether to advance to a justification view or skip straight to a confirmation:
app.view('step2_submit', async ({ ack, view }) => {
const state = JSON.parse(view.private_metadata || '{}');
state.level = view.state.values.lvl_block.lvl_input.selected_option.value;
const needsJustification = state.system === 'prod' || state.level === 'admin';
await ack({
response_action: 'update',
view: needsJustification
? buildJustificationView({ metadata: JSON.stringify(state) })
: buildConfirmView({ metadata: JSON.stringify(state) }),
});
});
Branch on data you’ve carried, not on what’s currently on screen. The metadata is your single source of truth for “where are we and what has the user told us so far.”
Showing field errors without nuking progress
When a step’s input is invalid, use response_action: "errors" keyed to the offending block_id. This is gentle: it keeps the current view open, shows the message under the field, and preserves everything entered. It does not advance the stack. This is how you validate per-step without the brutal experience of “submit, get rejected, start over.” On final submission, collapse the accumulated metadata into the actual action, then response_action: "clear" to dismiss the entire stack at once.
Where AI helps and where it bites
An LLM is good at generating the per-step view builders and the boilerplate of reading state and re-rendering. A prompt describing the flow gets you scaffolding fast:
Write Bolt handlers for a 3-step Slack modal: pick a system, pick an access level constrained by the system, and an optional justification step for prod or admin. Carry accumulated state in private_metadata as JSON. Use response_action: update to advance and clear to finish.
But the model has two reliable failure modes here, and a human must catch both. It forgets to carry private_metadata forward — so the wizard loses state — and it sometimes tries to views.push past the three-view limit. Both look fine in a code read and break in the workspace. I review every generated multi-step handler for those two specific bugs. The AI drafts the views; the human verifies state carries and the stack stays under three. Keep the corrected pattern in a prompt library and pair it with the related modal form design prompt so each new wizard starts from a known-good base.
Wrapping Up
Multi-step Slack modals are entirely doable, but they punish anyone who treats the view stack casually. Three rules carry you through: the stack is capped at three views so advance in place with views.update and reserve views.push for real branches; state never flows downhill on its own, so accumulate it in private_metadata and carry it on every transition; and validate per step with response_action: "errors" so a mistake never wipes a half-filled form. Let AI scaffold the views, but verify that state carries forward and the stack stays shallow — those two checks are what stand between a wizard people use and one that loses their work the moment they click Back.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.