# How to Create Modal Dialogs in Retool

- Tool: Retool
- Difficulty: Beginner
- Time required: 15-20 min
- Compatibility: Retool Cloud and Self-hosted
- Last updated: March 2026

## TL;DR

Create modals in Retool by adding a Modal component from the component panel. Open it with modal1.open() from a button click event handler or JS Query. Pass data into the modal by referencing table1.selectedRow.data inside modal components. Close it with modal1.close(). For confirmation dialogs, add Confirm and Cancel buttons that trigger the action query or close the modal.

## Building Modal Dialogs and Confirmation Popups in Retool

Modals in Retool are fullscreen overlays — they appear above the main canvas and focus the user on a specific task before returning to the main view. Retool provides two modal-style containers: Modal (centered popup) and Drawer (slides in from left, right, top, or bottom). Both work the same way programmatically.

Modals are best used for: editing a record selected from a table, confirming a destructive action before it executes, displaying a create-new-record form, and showing expanded detail views. They are single-view popups — for multi-step sequential flows, use the wizard-interfaces approach instead.

This tutorial builds three common modal patterns: a simple 'Edit Record' modal that pre-fills from a table row, a confirmation dialog that gates a delete action, and a 'Create New' modal that resets its form on open.

## Before you start

- A Retool app with a Table component and data
- Basic familiarity with event handlers and the Inspector panel

## Step-by-step guide

### 1. Add a Modal component to the canvas

From the component panel, drag a Modal component onto the canvas. It appears as a grayed-out overlay placeholder in edit mode — its actual size and appearance are configured in the Inspector. Rename it to editModal in the Name field at the top of the Inspector. In the Inspector's General section, configure the Title ('Edit Customer'), Width (Small/Medium/Large), and whether to show/hide the default close X button. The modal content canvas is visible in the edit view — drag components directly onto it.

**Expected result:** A Modal component named editModal appears in the component tree. Its content area is accessible in edit mode.

### 2. Open the modal from a button or table row click

Add a Button to the main canvas (outside the modal) with label 'Edit'. In the Button's Inspector → Interaction → Event Handlers, add an event handler: Event = 'Click', Action = 'Control component', Component = editModal, Action = 'open'. Click the button — the modal opens as an overlay. Alternatively, add the handler to a Table component's Row action column: in the Table Inspector → Columns, add a button column and set its Click action to open the modal.

```
// Button click event handler (Inspector → Interaction):
// Event: Click
// Action: Control component → editModal → open

// Table row action button configuration:
// In Table Inspector → Interaction → Row actions
// Add button: label 'Edit', onClick → Control component → editModal → open

// Open from a JS Query:
modal1.open();
// Or with additional context:
await selectedRecord.setValue(table1.selectedRow.data);
modal1.open();

// Open a Drawer from the right side (same API):
drawer1.open();
```

**Expected result:** Clicking the Edit button or table row action opens the modal as an overlay.

### 3. Pre-fill the modal form with the selected row's data

Inside the modal, add a Form component named editForm and add input fields: nameInput, emailInput, statusSelect. Set each input's Default Value to reference the corresponding field from table1.selectedRow.data. Since the modal opens when a row is selected, table1.selectedRow.data is available when the modal renders.

```
// Default Value expressions for fields inside editModal:

// nameInput Default Value:
{{ table1.selectedRow.data.full_name }}

// emailInput Default Value:
{{ table1.selectedRow.data.email }}

// statusSelect Default Value:
{{ table1.selectedRow.data.status }}

// createdAtText Value (read-only display):
{{ new Date(table1.selectedRow.data.created_at).toLocaleDateString() }}

// Hidden ID field (invisible, but accessible in form.data):
// Add a Number Input named recordIdInput
// Default Value: {{ table1.selectedRow.data.id }}
// Hidden: true (in Layout section)
```

**Expected result:** Opening the modal with a table row selected shows the form pre-filled with that row's data.

### 4. Wire the Save button inside the modal

Add a Button inside the modal with label 'Save'. Create a JS Query named updateRecord. The query reads form data from editForm.data, calls the database update mutation, and closes the modal on success. In the Save button's Click event handler, trigger updateRecord. Also add a Cancel button with a Click handler that calls editModal.close() and editForm.reset().

```
// JS Query: updateRecord
// Trigger: Save button click

const isValid = editForm.validate();
if (!isValid) {
  utils.showNotification({
    title: 'Please fix the errors',
    notificationType: 'warning',
  });
  return;
}

const data = editForm.data;

try {
  await updateCustomer.trigger({
    additionalScope: {
      id: data.recordIdInput,
      fullName: data.nameInput,
      email: data.emailInput,
      status: data.statusSelect,
    }
  });

  utils.showNotification({
    title: 'Saved',
    description: `${data.nameInput} updated successfully.`,
    notificationType: 'success',
  });

  editModal.close();
  await getCustomers.trigger(); // Refresh the table

} catch (err) {
  utils.showNotification({
    title: 'Save failed',
    description: err.message,
    notificationType: 'error',
  });
  throw err;
}
```

**Expected result:** Clicking Save validates the form, updates the record, closes the modal, and refreshes the table. Cancel closes without saving.

### 5. Build a confirmation dialog for destructive actions

Add a second Modal component named deleteConfirmModal. Give it a warning title: 'Delete Customer?' and add descriptive text: 'This action cannot be undone.' Add two buttons: 'Delete' (destructive, red styling) and 'Cancel'. Wire Delete to trigger a deleteCustomer JS Query. Wire Cancel to close the modal. Open deleteConfirmModal from the main table's Delete row action button.

```
// deleteConfirmModal content:
// Text component: 'Are you sure you want to delete {{ table1.selectedRow.data.full_name }}?\nThis action cannot be undone.'

// Delete button event handler:
// Click → Trigger query → deleteRecord

// JS Query: deleteRecord
try {
  await deleteCustomer.trigger({
    additionalScope: { id: table1.selectedRow.data.id }
  });

  utils.showNotification({
    title: 'Deleted',
    description: `${table1.selectedRow.data.full_name} has been removed.`,
    notificationType: 'success',
  });

  deleteConfirmModal.close();
  await getCustomers.trigger();

} catch (err) {
  utils.showNotification({
    title: 'Delete failed',
    description: err.message,
    notificationType: 'error',
  });
  throw err;
}

// Cancel button event handler:
// Click → Control component → deleteConfirmModal → close
```

**Expected result:** Clicking Delete on the table opens the confirmation modal. Confirming deletes the record and refreshes the table. Canceling closes without deleting.

### 6. Reset the modal form on open for a 'Create New' pattern

For a 'Create New' modal that should always start with an empty form, reset the form every time the modal opens. Instead of using a plain 'open' control action, trigger a JS Query from the button click that first resets the form, then opens the modal. This prevents previous user input from persisting when the modal is reopened.

```
// JS Query: openCreateModal
// Trigger: 'New Customer' button click

// Reset the form before opening
createForm.reset();

// Clear any pre-filled values from defaults
await createForm.setData({
  nameInput: '',
  emailInput: '',
  statusSelect: 'active', // sensible default for new records
  roleSelect: '',
});

// Open the modal
createModal.open();

// createModal save button JS Query:
// JS Query: createRecord
const data = createForm.data;
const isValid = createForm.validate();

if (!isValid) return;

await insertCustomer.trigger({
  additionalScope: {
    fullName: data.nameInput,
    email: data.emailInput,
    status: data.statusSelect,
    role: data.roleSelect,
  }
});

utils.showNotification({ title: 'Customer created', notificationType: 'success' });
createModal.close();
await getCustomers.trigger();
```

**Expected result:** Clicking 'New Customer' opens the modal with a clean empty form every time, even if the modal was previously used.

## Complete code example

File: `JS Query: updateRecord`

```javascript
// updateRecord — validates, saves, closes modal, refreshes table
// Trigger: Save button inside editModal
// Requires: editForm (Form component), editModal (Modal component)

const isValid = editForm.validate();

if (!isValid) {
  utils.showNotification({
    title: 'Form has errors',
    description: 'Please fill in all required fields correctly.',
    notificationType: 'warning',
  });
  return;
}

const data = editForm.data;

// Safety check — ensure we have a record ID
if (!data.recordIdInput) {
  utils.showNotification({
    title: 'Error',
    description: 'Record ID is missing. Please close and reopen the edit form.',
    notificationType: 'error',
  });
  return;
}

try {
  await updateCustomer.trigger({
    additionalScope: {
      id: data.recordIdInput,
      fullName: data.nameInput,
      email: data.emailInput,
      phone: data.phoneInput,
      status: data.statusSelect,
      notes: data.notesInput,
    }
  });

  utils.showNotification({
    title: 'Changes saved',
    description: `${data.nameInput}'s profile has been updated.`,
    notificationType: 'success',
  });

  editModal.close();

  // Refresh the parent table
  await getCustomers.trigger();

} catch (err) {
  utils.showNotification({
    title: 'Save failed',
    description: err.message || 'An unexpected error occurred.',
    notificationType: 'error',
  });
  throw err;
}
```

## Common mistakes

- **Opening a modal and expecting form Default Values to re-evaluate — Default Values are set at component mount, not at modal open time** — undefined Fix: Use form.setData() in the JS Query that opens the modal to explicitly set values from table1.selectedRow.data, rather than relying on Default Value expressions to update when the modal opens.
- **Not closing the modal after a successful save — the user sees no feedback that the action completed and is left with the modal still open** — undefined Fix: Always call modal1.close() in the success path of the save JS Query, after the notification. Then trigger a data refresh query to update the underlying table.
- **Adding the Cancel button's 'close modal' action directly in an event handler while the Save button triggers a JS Query that also closes the modal on success — this works fine, but adding an additional close handler to the Cancel path on top creates double-close errors** — undefined Fix: Keep close logic consistent: Cancel's Click → Control component → modal1 → close. Save's Click → JS Query → (close inside query on success). Do not mix event handler actions with JS Query close calls for the same modal.
- **Placing the Modal component inside a Container or other component — Retool requires Modal components to be at the root canvas level, not nested inside other containers** — undefined Fix: Drag the Modal component to the root of the canvas (not inside any Container, Tab, or other wrapper). Content placed inside the Modal's body can use any component types.

## Best practices

- Use descriptive modal names (editModal, deleteConfirmModal) — generic names like modal1 become confusing in apps with multiple modals
- Add a hidden ID input inside edit forms and populate it with the record's ID — this makes update queries simple and reliable
- Always validate the form before running the update query — call editForm.validate() and return early if it fails
- Refresh the parent table after a successful create/update/delete by triggering the data query after modal.close()
- Use a Drawer (slides in from the side) for complex edit forms that need more space — modals are for focused single-action dialogs
- Add a keyboard shortcut (Enter to save, Escape to cancel) inside modals for power users — configure via Key press event handlers
- Reset create-form modals before opening to prevent stale data from previous uses

## Frequently asked questions

### What is the difference between a Modal and a Drawer in Retool?

Both are overlay containers that appear above the main canvas. A Modal is a centered popup dialog — good for confirmations and focused forms. A Drawer slides in from a screen edge (left, right, top, or bottom) — good for settings panels and complex edit forms that benefit from full height or width. Both use the same .open() and .close() API.

### Can I prevent the modal from closing when the user clicks outside of it?

Yes. In the Modal component's Inspector → General, look for the 'Close on outside click' toggle and disable it. This forces users to use the explicit Close/Cancel button, which is appropriate for forms where accidental dismissal could lose work.

### How do I open a modal from inside a Table row's action button?

In the Table Inspector → Interaction → Row actions, add a button column. Set the button's onClick action to 'Control component' → your modal → 'open'. The table row's data is available as table1.selectedRow.data inside the modal because clicking the row action also selects that row.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-create-modal-dialogs-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-create-modal-dialogs-in-retool
