# How to Create Master-Detail Views in Retool

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

## TL;DR

A master-detail view pairs a Table (master) with a Container or Modal (detail) showing full information about the selected row. Bind the detail query to {{ table1.selectedRow.data.id }} with 'Run when inputs change' enabled, and display results in a detail Container. Use {{ table1.selectedRow }} to conditionally show or hide the detail panel.

## Build the Classic List-Plus-Detail UI Pattern in Retool

The master-detail pattern is one of the most common UI patterns in internal tools: a list of records on the left (the 'master'), and a full detail view of the selected record on the right (the 'detail'). This pattern is used everywhere — customer lists, order management, support ticket queues, inventory browsers.

In Retool, this pattern is implemented by combining a Table component (master) with a Container or Modal component (detail). The Table exposes a {{ table1.selectedRow }} property that contains the currently selected row's data. You bind a detail query to {{ table1.selectedRow.data.id }} so it automatically re-runs whenever a different row is clicked.

This tutorial walks through the full implementation: layout, query binding, conditional visibility, and inline editing.

## Before you start

- A Retool app with a database resource connected
- A query ('getItems') that fetches a list of records for the master table
- Basic familiarity with the Table and Container components

## Step-by-step guide

### 1. Set up the master Table and layout

Add a Table component to the left side of your canvas. Set its Data source to {{ getItems.data }} where getItems is your list query. Resize the Table to take up roughly 50-60% of the canvas width. On the right side, add a Container component. This Container will hold the detail view. Set its visible / Hidden property to {{ !table1.selectedRow }} so the detail panel hides automatically when nothing is selected. You can also add a 'Select a row to view details' placeholder text component that shows when table1.selectedRow is empty.

**Expected result:** Canvas has a Table on the left and an empty Container on the right. The Container is hidden until a row is selected.

### 2. Write the detail query and bind it to the selected row

Create a new SQL query named 'getItemDetail'. Write a SELECT query that fetches full record details using the selected row's ID as a parameter. Enable 'Run when inputs change' in the query's Advanced settings — this ensures the query re-runs automatically whenever {{ table1.selectedRow.data.id }} changes (i.e., whenever the user clicks a different row). Do not enable 'Run on page load' for this query — it should only fire after row selection.

```
-- getItemDetail SQL query
SELECT
  i.*,
  u.name AS created_by_name,
  u.email AS created_by_email,
  c.name AS category_name
FROM items i
LEFT JOIN users u ON i.created_by = u.id
LEFT JOIN categories c ON i.category_id = c.id
WHERE i.id = {{ table1.selectedRow.data.id }}
LIMIT 1;
```

**Expected result:** Clicking a row in the master table triggers getItemDetail and populates {{ getItemDetail.data[0] }}.

### 3. Populate the detail Container with components

Inside the Container component, add display components bound to {{ getItemDetail.data[0].fieldName }}. For example: a Text component showing '# {{ getItemDetail.data[0].id }} — {{ getItemDetail.data[0].title }}', several key-value Text components for fields like status, category, and created_at, and a Text Area showing the description. Wrap everything in a loading spinner using the Container's Loading state set to {{ getItemDetail.isFetching }}.

```
// Text component values inside the detail Container:
// Title: {{ getItemDetail.data[0].title }}
// Status: {{ getItemDetail.data[0].status }}
// Created: {{ moment(getItemDetail.data[0].created_at).format('MMM D, YYYY') }}
// Description: {{ getItemDetail.data[0].description }}
```

**Expected result:** Clicking a table row populates the right-side Container with the full details of that record.

### 4. Add inline editing in the detail panel

Replace some display Text components with editable inputs (Text Input, Select dropdown) so users can edit records directly in the detail panel. Add a 'Save Changes' Button at the bottom of the Container. When clicked, the button triggers an UPDATE query named 'updateItem' that uses the form values. Use {{ table1.selectedRow.data.id }} as the WHERE clause ID. After the update succeeds, chain a query trigger to re-run getItems (to refresh the master table) and getItemDetail (to reflect the saved values).

```
-- updateItem SQL query (triggered by Save button)
UPDATE items
SET
  title = {{ titleInput.value }},
  status = {{ statusSelect.value }},
  description = {{ descriptionTextarea.value }},
  updated_at = NOW()
WHERE id = {{ table1.selectedRow.data.id }}
RETURNING *;
```

**Expected result:** User can edit fields in the detail panel and save changes. Master table refreshes to show updated values.

### 5. Handle the no-selection state gracefully

When the page loads, no row is selected and table1.selectedRow is undefined. Handle this with two approaches: (1) Set the detail Container's Hidden property to {{ !table1.selectedRow }} — it disappears when nothing is selected. (2) Add an empty state placeholder: a Text component outside the Container with the message 'Click a row to view details' and set its Hidden to {{ !!table1.selectedRow }}. This creates a clean UX where the placeholder shows until the user makes a selection.

```
// Container Hidden property:
{{ !table1.selectedRow }}

// Placeholder Text Hidden property (inverse):
{{ !!table1.selectedRow }}

// Safe access pattern for detail fields (prevents undefined errors):
{{ table1.selectedRow?.data?.title ?? 'No record selected' }}
```

**Expected result:** Page loads with a 'Click a row to view details' message. Clicking a row hides the placeholder and shows the detail panel.

### 6. Optionally use a Modal instead of a side Container

For narrower canvases or when you want a full-screen detail view, replace the side Container with a Modal. Name it 'detailModal'. In the Table's event handlers, add an 'On row click' handler with action 'Open modal' pointing to detailModal. Move your detail components inside the Modal body. The Modal variant gives more vertical space for complex detail forms and is better for edit workflows where you want a clear 'Save / Cancel' pattern.

```
// Table 'On row click' event handler:
// Action: Control component
// Component: detailModal
// Method: open()

// Or via JS Query:
await detailModal.open();
await getItemDetail.trigger();
```

**Expected result:** Clicking a table row opens the detail Modal with the full record loaded.

## Complete code example

File: `JS Query: openDetailAndLoad`

```javascript
// JS Query: openDetailAndLoad
// Used as the Table's 'On row click' event handler
// Ensures detail data loads before the modal opens

// Trigger the detail query with the current selected row ID
await getItemDetail.trigger({
  additionalScope: {
    selectedId: table1.selectedRow.data.id,
  },
});

// Open the modal after data has loaded
await detailModal.open();

// Note: setValue() is async — if you need to store the selected ID
// in a temporary state variable, don't read it immediately:
// await selectedIdVar.setValue(table1.selectedRow.data.id);
// The value is not readable synchronously after setValue().
```

## Common mistakes

- **Accessing table1.selectedRow.data.id directly without checking if selectedRow exists, causing errors on page load** — undefined Fix: Use optional chaining: {{ table1.selectedRow?.data?.id ?? -1 }}. The fallback value -1 prevents the detail query from returning unwanted results.
- **Forgetting to enable 'Run when inputs change' on the detail query, so it only loads once on page load** — undefined Fix: In the detail query's Advanced settings, enable 'Run when inputs change'. This makes Retool watch all {{ }} references in the query and re-run it whenever they change.
- **Event handlers running in parallel causing the modal to open before the detail query finishes loading** — undefined Fix: Use a JS Query with await to sequence the operations: await getItemDetail.trigger() first, then await detailModal.open(). Parallel event handlers cannot guarantee execution order.
- **Setting setValue() for a temporary state variable and reading it immediately after in the same function** — undefined Fix: setValue() is async. The updated value is not available synchronously. Use the source value directly (table1.selectedRow.data.id) rather than reading back from the state variable in the same flow.

## Best practices

- Always add a safe fallback for selectedRow references: {{ table1.selectedRow?.data?.id ?? '' }} prevents undefined errors on page load
- Use 'Run when inputs change' on the detail query instead of manually triggering it in every event handler
- Show a loading state in the detail Container using the Container's Loading property bound to {{ getItemDetail.isFetching }}
- For edit forms in the detail panel, reset input values to the current record's data when the selection changes using Default value bindings
- If the detail panel has many fields, consider a Modal over a side Container — it gives more space and separates view vs edit contexts
- Refresh the master table after saving edits: chain getItems.trigger() in the updateItem 'on success' event handler

## Frequently asked questions

### What does table1.selectedRow contain in Retool?

table1.selectedRow is an object with two keys: data (the full row object with all column values) and index (the zero-based row position in the table). Access specific fields as {{ table1.selectedRow.data.columnName }}. When no row is selected, selectedRow is undefined — always use optional chaining to avoid errors.

### Can I have a master-detail view where multiple rows can be selected?

Retool's built-in selectedRow only tracks a single selection. For multi-selection, enable the Table's 'Allow multi-select' option and use {{ table1.selectedRows }} (plural) which returns an array of all selected rows. Your detail panel would need to either show the first selection or aggregate across all selected rows.

### How do I pre-select a row on page load in a master-detail view?

Use a JS Query set to 'Run on page load' that calls table1.selectRow(0) to select the first row. Alternatively, use table1.selectRow(table1.data.findIndex(r => r.id === someTargetId)) to pre-select a specific record, for example from a URL parameter.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-create-master-detail-views-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-create-master-detail-views-in-retool
