SapotaCorp

Slack Modals and Views: Push, Update, and Publish

A slack modal is the workhorse of any serious Slack app: forms, approvals, multi-step flows, and live dashboards all run through views. This is the field guide we wish we had before shipping our first one, with real view JSON and the pitfalls that bit us in production.

Slack Modals and Views: Push, Update, and Publish

Key takeaways

  • A slack modal is just a view object with a callback_id, a title, and blocks; the submit button is what makes it interactive and routes a view_submission event to your handler.
  • Input blocks are the core of every form, and you read their values through the exact path view.state.values[block_id][action_id], which is the single most common place people get the path wrong.
  • views.push stacks a modal up to three deep, views.update swaps the current view in place, and views.publish renders the App Home tab; pick based on whether you are adding, replacing, or building a dashboard.
  • You must ack within three seconds, so validate synchronously and run slow database work after the ack, otherwise the user sees an operation-failed message and your record never gets created.
  • Mobile is not a free pass: keep titles under 24 characters, actions blocks to three buttons, and always test on a real phone before shipping.

A slack modal is the difference between a bot that pastes text into a channel and an app that people actually use to get work done. The moment you need a structured form, a confirmation step, or a multi-stage approval, you reach for views. We have shipped these for an approval flow at a Vietnamese commercial bank, an on-call dashboard at a large engineering org, and an ops triage tool at an e-commerce operations team, and the same handful of mechanics show up every time. This is what they are and where they break.

What a view actually is

A modal is a popup that overlays the Slack client on both desktop and mobile. Unlike a message, it does not persist in the channel timeline; close it and it is gone. You open one with views.open, you stack another on top with views.push, and you change the one already on screen with views.update. All three take a view object, which is the single structure you will spend most of your time building.

{
  "type": "modal",
  "callback_id": "campaign_modal",
  "title": { "type": "plain_text", "text": "Submit Campaign" },
  "submit": { "type": "plain_text", "text": "Send for approval" },
  "close": { "type": "plain_text", "text": "Cancel" },
  "private_metadata": "{}",
  "external_id": "campaign-create-2026-05-20-001",
  "blocks": []
}

A few of these fields carry more weight than their size suggests. The callback_id is how Bolt routes the view_submission event back to your handler, so it has to be stable and unique per form. The title is a hard 24-character limit, and you will hit it. If you omit submit, the modal becomes view-only and fires no submission at all, which is exactly what you want for a read-only detail popup. The private_metadata field is a JSON string capped at 3000 characters that survives across steps, and external_id is the handle a background job uses to find and update this view later. Hold onto those last two; they unlock the interesting flows.

Input blocks are the form

Every field in a modal lives inside an input block, which is a block type that only works in modals. It wraps one form element plus a label.

{
  "type": "input",
  "block_id": "budget_block",
  "label": { "type": "plain_text", "text": "Budget (VND)" },
  "hint": { "type": "plain_text", "text": "Minimum 1M, maximum 5B" },
  "optional": false,
  "element": {
    "type": "number_input",
    "action_id": "budget_input",
    "is_decimal_allowed": false,
    "min_value": "1000000",
    "max_value": "5000000000"
  }
}

The thing to burn into memory is how you read the value back. It is always view.state.values[block_id][action_id], never the action_id alone and never a guess. The number-one bug we see is code reaching for values.budget.budget_input.value when the block_id is actually budget_block. When you are developing, log view.state.values once and copy the real path out of it.

Two more details that cause real grief. Fields are required by default, so any field a user can legitimately skip needs optional: true on the input block or the modal refuses to submit. And min_value / max_value on a number input are strings on purpose, because JavaScript number precision cannot be trusted with large integers like a five-billion-dong budget cap.

Validating on submit without blowing the 3-second budget

When the user hits submit, Slack sends a view_submission event and starts a three-second clock. If you have not acknowledged it by then, the user sees "operation failed" and your write never lands. So validation has to be layered.

Slack handles the first layer for free: max length, min length, email and URL format, number range. The second layer is cross-field logic you run synchronously and return through the ack. The third layer, anything that needs a slow database query, runs after the ack.

app.view('campaign_modal', async ({ ack, view, body, client }) => {
  const v = view.state.values;
  const startDate = v.start_block.start_date.selected_date;
  const endDate = v.end_block.end_date.selected_date;
  const channels = v.channel_block.channels.selected_options.map(o => o.value);

  const errors = {};
  if (endDate <= startDate) errors.end_block = 'End date must be after start date';
  if (channels.length === 0) errors.channel_block = 'Pick at least one channel';
  if (Object.keys(errors).length > 0) {
    await ack({ response_action: 'errors', errors });
    return;
  }

  await ack(); // inside the 3s window

  // slow work happens here, after the ack
  const campaignId = await createCampaign({ startDate, endDate, channels, requester: body.user.id });
  await client.chat.postMessage({ channel: body.user.id, text: `Created campaign ${campaignId}` });
});

The response_action: 'errors' shape is an object keyed by block_id, and it renders the message right under the offending field without closing the modal. That keeps the user in place to fix it. Anything you can only learn after a database round trip, like a duplicate name or a blown quota, you cannot show in the modal because it is gone by then, so DM the user the reason instead.

Push, update, and publish

These three methods are the whole vocabulary, and choosing between them is mostly about whether you are adding, replacing, or building a dashboard.

views.push stacks a new modal on top of the current one, up to three deep. The cleanest way to do it is not a separate API call but a response action right inside the submit handler of the previous step:

app.view('campaign_step1', async ({ ack, view, body }) => {
  const type = view.state.values.type_block.type_select.selected_option.value;
  await ack({
    response_action: 'push',
    view: buildStep2(type, body.user.id)
  });
});

This is how you build a multi-step flow where step two depends on step one. The bank's VIP campaign form runs three steps: pick a campaign type, fill the basic fields, then add channel mix and compliance checks. Each step carries the accumulated answers forward in private_metadata, and the final submit calls ack({ response_action: 'clear' }) to tear down the whole stack at once. Push from a button works the same way, except the trigger_id from a button action is only alive for three seconds, so you push immediately after ack and never run a query first.

views.update swaps the contents of the modal already on screen without touching the stack. It needs a view_id from the payload, or an external_id you set yourself. The everyday use is a dependent dropdown: the user picks a country, you fetch the cities, and you rebuild the same view in place.

app.action('country_select', async ({ ack, body, client }) => {
  await ack();
  const cities = await loadCitiesByCountry(body.actions[0].selected_option.value);
  await client.views.update({ view_id: body.view.id, view: rebuildWithCities(body.view, cities) });
});

The external_id variant is what makes long-running work feel instant. Open a modal that just says "Generating report" with external_id: "report-{user}-{timestamp}", enqueue the job, and let the background worker call views.update against that same external_id when it finishes thirty seconds later. The user watches the modal change on its own. Make the id globally unique, because two modals sharing an external_id will stomp on each other, and always finish the job plus DM the result even if the user closed the modal in the meantime, since the update silently no-ops against a view that no longer exists.

views.publish renders the App Home tab, the view people see when they click your app in the sidebar. It is a home-type view with no submit and no close, just static blocks and buttons.

app.event('app_home_opened', async ({ event, client }) => {
  const blocks = await buildUserDashboard(event.user);
  await client.views.publish({ user_id: event.user, view: { type: 'home', blocks } });
});

The large engineering org puts this week's on-call rota here with a quick-swap link; the ops team lists the ten most recent issues with a quick-resolve button. One trap: app_home_opened fires often, and republishing on every open will hit the rate limit, so cache the rendered view in Redis with a short TTL and only publish on a cache miss.

Mobile is where it falls apart

Modals look fine on a 27-inch monitor and fall to pieces on a phone, and most teams only find out after a user complains. Keep the modal title under 24 characters, and under 20 if you want it to look right on mobile. Hold actions blocks to three buttons so they sit on one row; a fourth wraps into a stretched full-width column that looks broken. Button text over fifteen characters wraps to two lines inside the button. Headers get cut after roughly thirty characters, so put severity and SLA into section fields rather than the header, where "Issue ORDER-123 P1 Critical SLA expired" becomes "Issue ORDER-123 P1 Critic..." and the part that mattered is gone.

A couple of structural habits help here too. Modals have no real two-column layout, so mock it with a section fields array for read-only metadata and stack input blocks one per row for the editable parts. And while modals allow 100 blocks, anything past 30 makes mobile scrolling miserable, so paginate long lists instead of cramming them in. None of this is hard, it just has to be on a checklist, because the only reliable test is opening the thing on an actual phone.

Takeaway

The mental model is small once it clicks. A modal is a view object, input blocks hold the data, you read it at view.state.values[block_id][action_id], and you ack inside three seconds. Push to stack, update to replace in place, publish for App Home, and carry state across steps in private_metadata or a key stashed in external_id. Get those right and the rest is layout and a mobile checklist.

If you are designing an internal Slack app and want a second set of eyes on the flow, or you would rather hand the whole build to people who have shipped these before, get in touch or take a look at what we do.

Engineering certifications

Sapota engineers hold credentials on Slack. Each badge links to the individual engineer's credly profile.

Browse Slack certs

Need this on your team?

Sapota engineers ship the patterns you read here. Two-week paid trial, direct pricing from $1,800/ engineer/month, no agency markup.

Get a quote
Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project