Skip to main content
RapidDev - Software Development Agency
retool-tutorial

How to Create Master-Detail Views in Retool

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.

What you'll learn

  • Use {{ table1.selectedRow.data }} to access the selected row's full data object
  • Trigger a detail query automatically when a row is selected using 'Run when inputs change'
  • Display detail data in a side Container or Modal component beside the master table
  • Enable inline editing in the detail panel and save changes back to the database
  • Handle the case where no row is selected using conditional visibility
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner7 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
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.

Quick facts about this guide
FactValue
ToolRetool
DifficultyBeginner
Time required20-25 min
CompatibilityRetool Cloud and Self-hosted
Last updatedMarch 2026

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.

Prerequisites

  • 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.

typescript
1-- getItemDetail SQL query
2SELECT
3 i.*,
4 u.name AS created_by_name,
5 u.email AS created_by_email,
6 c.name AS category_name
7FROM items i
8LEFT JOIN users u ON i.created_by = u.id
9LEFT JOIN categories c ON i.category_id = c.id
10WHERE i.id = {{ table1.selectedRow.data.id }}
11LIMIT 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 }}.

typescript
1// Text component values inside the detail Container:
2// Title: {{ getItemDetail.data[0].title }}
3// Status: {{ getItemDetail.data[0].status }}
4// Created: {{ moment(getItemDetail.data[0].created_at).format('MMM D, YYYY') }}
5// 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).

typescript
1-- updateItem SQL query (triggered by Save button)
2UPDATE items
3SET
4 title = {{ titleInput.value }},
5 status = {{ statusSelect.value }},
6 description = {{ descriptionTextarea.value }},
7 updated_at = NOW()
8WHERE id = {{ table1.selectedRow.data.id }}
9RETURNING *;

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.

typescript
1// Container Hidden property:
2{{ !table1.selectedRow }}
3
4// Placeholder Text Hidden property (inverse):
5{{ !!table1.selectedRow }}
6
7// Safe access pattern for detail fields (prevents undefined errors):
8{{ 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.

typescript
1// Table 'On row click' event handler:
2// Action: Control component
3// Component: detailModal
4// Method: open()
5
6// Or via JS Query:
7await detailModal.open();
8await getItemDetail.trigger();

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

Complete working example

JS Query: openDetailAndLoad
1// JS Query: openDetailAndLoad
2// Used as the Table's 'On row click' event handler
3// Ensures detail data loads before the modal opens
4
5// Trigger the detail query with the current selected row ID
6await getItemDetail.trigger({
7 additionalScope: {
8 selectedId: table1.selectedRow.data.id,
9 },
10});
11
12// Open the modal after data has loaded
13await detailModal.open();
14
15// Note: setValue() is async — if you need to store the selected ID
16// in a temporary state variable, don't read it immediately:
17// await selectedIdVar.setValue(table1.selectedRow.data.id);
18// The value is not readable synchronously after setValue().

Common mistakes when creating Master-Detail Views in Retool

Why it's a problem: Accessing table1.selectedRow.data.id directly without checking if selectedRow exists, causing errors on page load

How to avoid: Use optional chaining: {{ table1.selectedRow?.data?.id ?? -1 }}. The fallback value -1 prevents the detail query from returning unwanted results.

Why it's a problem: Forgetting to enable 'Run when inputs change' on the detail query, so it only loads once on page load

How to avoid: 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.

Why it's a problem: Event handlers running in parallel causing the modal to open before the detail query finishes loading

How to avoid: 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.

Why it's a problem: Setting setValue() for a temporary state variable and reading it immediately after in the same function

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm building a master-detail view in Retool with: (1) a Table showing a list of support tickets from a 'getTickets' query, (2) a detail Container that shows full ticket info when a row is clicked, (3) the detail loaded by a 'getTicketDetail' query with WHERE id = {{ table1.selectedRow.data.id }} and 'Run when inputs change' enabled, (4) edit fields in the Container and a Save button that runs an updateTicket SQL query. Write the updateTicket SQL, the Container Hidden expression, and a JS Query for sequencing the detail load before opening the modal.

Retool Prompt

Build a Retool master-detail layout for a customers table. The master Table uses getCustomers query. The detail panel shows {{ getCustomerDetail.data[0] }} fields (name, email, plan, created_at). The getCustomerDetail query runs with WHERE id = {{ table1.selectedRow?.data?.id ?? -1 }} and 'Run when inputs change' on. Show the Container Hidden expression and a safe pattern for displaying detail fields.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Learning is great. Shipping is faster with help.

Our engineers have built 600+ apps on Retool and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.