Skip to content
Block Kit for React
Esc
↑↓navigate↵open⌘Jpreview
On this page

Modals

Open, push and update modals from an action, validate and submit them, and react to response_action.

A modal is a <Modal> rendering a view: a title, a scrollable body of blocks, and an optional close/submit footer. You rarely render one directly: instead a button’s onAction opens it through views, the same views.open / views.push / views.update an app would call.

Your AppAPP
onActionInteract with the preview.

Opening a modal from a button

views is on every ActionContext (see Handling actions). Call views.open with a view object with no id, hash or root_view_id; the provider assigns those the way Slack does:

import { BlockKitProvider, Message, type ViewsApi } from "@nkootstra/block-kit";

function openIssueModal(views: ViewsApi) {
  views.open({
    type: "modal",
    callback_id: "report_issue",
    title: { type: "plain_text", text: "Report an issue" },
    submit: { type: "plain_text", text: "Submit" },
    close: { type: "plain_text", text: "Cancel" },
    blocks: [
      {
        type: "input",
        block_id: "summary",
        label: { type: "plain_text", text: "Summary" },
        element: { type: "plain_text_input", action_id: "value" },
      },
    ],
  });
}

export function App() {
  return (
    <BlockKitProvider
      onAction={(action, { views }) => {
        if (action.action_id === "open") openIssueModal(views);
      }}
    >
      <Message
        blocks={[
          {
            type: "actions",
            block_id: "actions",
            elements: [
              {
                type: "button",
                action_id: "open",
                text: { type: "plain_text", text: "Report an issue" },
              },
            ],
          },
        ]}
      />
    </BlockKitProvider>
  );
}

The provider renders any opened modal on top of your page automatically. You don’t add <Modal> yourself for this flow.

views.open / push / update / close / clear

PropType
open?(view) => string

Replaces any open modals with this one. Returns the new view's id.

Type(view) => string
push?(view) => string

Stacks a modal on top of the current one, keeping the one below. Slack allows at most 3 views in a stack.

Type(view) => string
update?(view, target?) => void

Swaps a modal's content in place, keeping its id and matching state. target is { viewId } or { externalId }; defaults to the top modal.

Type(view, target?) => void
close?() => void

Closes the top modal, returning to the one below it.

Type() => void
clear?() => void

Closes every open modal.

Type() => void
publish?(view) => void

views.publish: replaces the content of the <HomeTab> rendered under this provider.

Type(view) => void

Pushing past the 3-view limit is a no-op (with a console warning), matching what Slack’s API would reject.

onSubmit and response_action

A modal with submit calls onSubmit when its Submit button is pressed, with the same view_submission payload Slack sends. Return a response_action to react like an app would; return nothing to just close the modal.

<BlockKitProvider
  onSubmit={(payload, { views }) => {
    const summary = payload.view.state.values.summary?.value?.value;
    if (!summary) {
      return { response_action: "errors", errors: { summary: "Tell us what happened." } };
    }
    // Persist it, then just close the modal (return nothing), or:
    return {
      response_action: "update",
      view: {
        type: "modal",
        title: { type: "plain_text", text: "Report an issue" },
        close: { type: "plain_text", text: "Done" },
        blocks: [{ type: "section", text: { type: "mrkdwn", text: "Thanks, we'll take a look." } }],
      },
    };
  }}
>
PropType
response_action: "errors"?{ errors: Record<string, string> }

Shows a message under each named block_id and keeps the modal open.

Type{ errors: Record<string, string> }
response_action: "update"?{ view: ViewLike }

Replaces the current view's content, keeping its id in the stack.

Type{ view: ViewLike }
response_action: "push"?{ view: ViewLike }

Pushes a new view on top, like views.push.

Type{ view: ViewLike }
response_action: "clear"?{}

Closes the entire modal stack.

Type{}

If onSubmit throws or its promise rejects, the same as an app’s request timing out or answering with an error, the modal stays open, matching Slack’s behavior when it can’t reach your app.

Validation errors

Before onSubmit is even called, Slack’s own client-side checks run: required inputs, min/max text length, number format, and email/URL format. A failing field shows its message right under the input and the submission never fires. See Validation for the exact rules and messages.

Errors your onSubmit returns via response_action: "errors" layer on top of those and clear once the field’s value changes, exactly as Slack’s client behaves.

You can also seed a modal with errors up front, useful for a standalone <Modal> you’re previewing outside the views flow, with the errors prop on <BlockKitProvider>:

<BlockKitProvider errors={{ summary: "This field is required." }}>

onClose

Fires with a view_closed payload when the modal’s close (X) button, or its close footer button on the root view, is pressed.

<BlockKitProvider
  onClose={(payload) => {
    console.log("closed", payload.view.callback_id, "cleared:", payload.is_cleared);
  }}
>

Like Slack, a pushed view only reports view_closed for its own dismissal when its notify_on_close field is true. A root view (or a standalone <Modal>) always reports it, since something needs to know to stop showing it.

The 3-view stack limit

Slack caps a modal stack at 3 views. views.push beyond that returns "" and does nothing (with a console warning) instead of stacking a 4th view. Build your flow assuming callers check the id, or just keep flows to 2-3 steps as Slack recommends.

Rendering a modal directly

For a modal that isn’t opened from a button (a docs example, a settings panel embedded in your own page), render <Modal> (or the surface-agnostic <View>) yourself:

import { BlockKitProvider, Modal } from "@nkootstra/block-kit";

<BlockKitProvider surface="modal">
  <Modal
    view={{
      type: "modal",
      title: { type: "plain_text", text: "New ticket" },
      submit: { type: "plain_text", text: "Create" },
      close: { type: "plain_text", text: "Cancel" },
      blocks: [{ type: "section", text: { type: "plain_text", text: "Body" } }],
    }}
  />
</BlockKitProvider>;

useLiveView

A standalone <Modal>/<HomeTab> you render this way can still be updated by views.update / views.publish from elsewhere in the tree. <Modal> and <HomeTab> use useLiveView internally to track that. If you’re building a custom surface and want the same “the app’s last update wins, until the view prop changes by content” behavior, use it directly:

import { useLiveView } from "@nkootstra/block-kit";

function CustomSurface({ view: viewProp }: { view: ModalView }) {
  const { view, update } = useLiveView(viewProp);
  // `view` reflects the latest views.update()/views.publish() call for this surface,
  // or `viewProp` again once its content changes.
  return <Modal view={view} />;
}

Was this page helpful?