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

How to Update Data in a Retool Table

Enable inline table editing by toggling 'Editable' on individual columns in the Table Inspector. Changed cells accumulate in {{ table1.changesetArray }} as an array of modified rows. Add a Save Changes button that triggers a SQL UPDATE query using table1.changesetArray. For new rows, read {{ table1.newRows }} and run an INSERT query.

What you'll learn

  • Enable editable columns in the Table component Inspector to allow inline cell editing
  • Access pending changes via {{ table1.changesetArray }} and submit them with a Save Changes button
  • Write a SQL UPDATE query that iterates over table1.changesetArray to bulk-update changed rows
  • Use {{ table1.newRows }} to capture and insert newly added rows from the table
  • Handle the editable table pattern with validation and rollback on error
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
TL;DR

Enable inline table editing by toggling 'Editable' on individual columns in the Table Inspector. Changed cells accumulate in {{ table1.changesetArray }} as an array of modified rows. Add a Save Changes button that triggers a SQL UPDATE query using table1.changesetArray. For new rows, read {{ table1.newRows }} and run an INSERT query.

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

Inline Editing and Bulk Updates in Retool Tables

Retool's Table component supports inline editing — users click a cell and type directly in the table without opening a separate form. Changes accumulate in the table's pending state and are not committed to the database until the user clicks a Save Changes button you configure.

This tutorial covers the complete editable table workflow: enabling editable columns, understanding the changesetArray structure, writing the SQL UPDATE query that processes bulk changes, handling new row insertion with newRows, and adding validation before saving.

The editable table pattern is ideal for spreadsheet-style data entry workflows where users need to edit many rows quickly. For complex edit scenarios with dependent fields or file uploads, a modal edit form (covered in the create-modal-dialogs tutorial) is more appropriate.

Prerequisites

  • A Retool app with a Table component displaying data from a SQL query
  • A database resource with write permissions
  • Basic familiarity with SQL UPDATE statements and JS Queries

Step-by-step guide

1

Enable editable columns in the Table Inspector

Select the Table component. In the Inspector, go to the Columns section. For each column you want to make editable, find the column entry and toggle 'Editable' to ON. You can also configure the input type for editable columns: Text (default), Number, Select (dropdown with options), Date, Switch (boolean), or Custom. Set appropriate column types to get the right editor widget when users click the cell.

typescript
1// Column configuration in Table Inspector:
2// full_name column:
3// Editable: ON
4// Input type: Text
5// Placeholder: 'Enter full name'
6
7// status column:
8// Editable: ON
9// Input type: Select
10// Options: {{ [{ label: 'Active', value: 'active' }, { label: 'Inactive', value: 'inactive' }] }}
11
12// price column:
13// Editable: ON
14// Input type: Number
15// Min: 0
16
17// id column:
18// Editable: OFF (always — never let users edit primary keys)

Expected result: Editable columns display an edit cursor when hovered. Clicking a cell opens an inline editor appropriate for the column type.

2

Understand the changesetArray structure

When users edit cells, the Table component accumulates changes in {{ table1.changesetArray }}. This is an array of objects, one per modified row. Each object contains the row's original data plus only the changed columns. The structure allows you to build a SQL UPDATE that processes only rows that were actually modified, not the entire table.

typescript
1// table1.changesetArray structure:
2// Each element represents one modified row
3[
4 {
5 id: 42, // primary key (original)
6 full_name: 'Jane Smith', // updated value
7 // other columns unchanged — only changed fields appear
8 },
9 {
10 id: 17,
11 status: 'inactive', // updated value
12 price: 299.99, // also updated in this row
13 }
14]
15
16// Access in expressions:
17{{ table1.changesetArray }}
18{{ table1.changesetArray.length }}
19// Returns number of modified rows
20
21// Has pending changes:
22{{ table1.changesetArray.length > 0 }}
23
24// Check if any row has a specific column changed:
25{{ table1.changesetArray.some(r => 'price' in r) }}

Expected result: After editing cells, {{ table1.changesetArray.length }} returns the number of rows with pending changes.

3

Add a Save Changes button and configure it

Add a Button component above or below the Table with label 'Save Changes'. In the Inspector, set its Disabled property to {{ table1.changesetArray.length === 0 }} so it is greyed out when there are no pending changes. Set its Loading State property to {{ saveChanges.isFetching }} so a spinner shows during the save operation. Wire its Click event to trigger a JS Query named saveChanges.

typescript
1// Save Changes button Inspector settings:
2// Label: {{ `Save Changes (${table1.changesetArray.length})` }}
3// Shows count of pending changes
4// Disabled: {{ table1.changesetArray.length === 0 }}
5// Loading State: {{ saveChanges.isFetching }}
6
7// Optional: Add a Discard Changes button
8// Label: 'Discard'
9// Disabled: {{ table1.changesetArray.length === 0 }}
10// Click event: Control component → table1 → discardChanges

Expected result: Save Changes button is disabled when no edits are pending. Clicking it shows a loading state during the save.

4

Write the bulk UPDATE query using changesetArray

Create a Resource Query named updateRows (or configure inline in a JS Query). The UPDATE query needs to process each row in changesetArray. The most reliable approach for SQL databases is a series of individual UPDATE statements executed in sequence from a JS Query, or a single statement with CASE expressions. For PostgreSQL, use unnest() with a multi-column UPDATE. The JS Query approach with individual updates per row is the clearest and most database-agnostic.

typescript
1// JS Query: saveChanges
2// Trigger: Save Changes button click
3
4const changes = table1.changesetArray;
5
6if (changes.length === 0) return;
7
8try {
9 // Trigger the SQL query once per changed row
10 // (Use Promise.all for parallel execution)
11 await Promise.all(
12 changes.map(row =>
13 updateSingleRow.trigger({
14 additionalScope: {
15 id: row.id,
16 fullName: row.full_name ?? null,
17 email: row.email ?? null,
18 status: row.status ?? null,
19 price: row.price ?? null,
20 }
21 })
22 )
23 );
24
25 utils.showNotification({
26 title: 'Saved',
27 description: `${changes.length} row${changes.length > 1 ? 's' : ''} updated.`,
28 notificationType: 'success',
29 });
30
31 await getTableData.trigger(); // Refresh
32
33} catch (err) {
34 utils.showNotification({
35 title: 'Save failed',
36 description: err.message,
37 notificationType: 'error',
38 });
39 throw err;
40}

Expected result: Clicking Save Changes updates all modified rows in the database and refreshes the table.

5

Write the SQL UPDATE query for a single row

Create a SQL Resource Query named updateSingleRow that takes the row fields as parameters from additionalScope. Use COALESCE to only update columns that were actually changed — pass null for unchanged columns and the query uses the existing database value. This prevents overwriting columns that were not part of the edit.

typescript
1-- SQL query: updateSingleRow
2-- Mode: Manual (triggered from JS Query)
3UPDATE customers
4SET
5 full_name = COALESCE({{ fullName }}, full_name),
6 email = COALESCE({{ email }}, email),
7 status = COALESCE({{ status }}, status),
8 price = COALESCE({{ price }}, price),
9 updated_at = NOW()
10WHERE id = {{ id }}
11RETURNING id, full_name, email, status, price, updated_at;
12
13-- COALESCE: if the parameter is null, keep the existing column value
14-- This means passing null for 'fullName' leaves full_name unchanged

Expected result: Each changed row is updated in the database. RETURNING shows the post-update values including updated_at.

6

Handle new row insertion with table1.newRows

The Table component also supports adding new rows inline. Enable 'Allow adding rows' in the Table Inspector. When users click '+ Add row', a blank row appears. New rows are accessible via {{ table1.newRows }}. Write a JS Query that reads table1.newRows and runs an INSERT for each new row, similar to the UPDATE pattern.

typescript
1// JS Query: saveNewRows
2// Trigger: separate 'Save New Rows' button or combine with saveChanges
3
4const newRows = table1.newRows;
5
6if (newRows.length === 0) return;
7
8try {
9 await Promise.all(
10 newRows.map(row =>
11 insertSingleRow.trigger({
12 additionalScope: {
13 fullName: row.full_name,
14 email: row.email,
15 status: row.status || 'active',
16 price: row.price,
17 }
18 })
19 )
20 );
21
22 utils.showNotification({
23 title: 'Rows inserted',
24 description: `${newRows.length} new row${newRows.length > 1 ? 's' : ''} added.`,
25 notificationType: 'success',
26 });
27
28 await getTableData.trigger();
29
30} catch (err) {
31 utils.showNotification({
32 title: 'Insert failed',
33 description: err.message,
34 notificationType: 'error',
35 });
36 throw err;
37}

Expected result: New rows added via the inline '+ Add row' feature are inserted into the database. {{ table1.newRows.length }} shows pending new rows.

7

Add row-level validation before saving

Before saving, validate that required columns in changesetArray have non-empty values. Add validation logic to the saveChanges JS Query that checks each changed row for required fields. Display field-specific error notifications so users know exactly which rows need attention.

typescript
1// JS Query: saveChanges (with validation)
2const changes = table1.changesetArray;
3
4if (changes.length === 0) return;
5
6// Validate required fields in changed rows
7const invalidRows = changes.filter(row => {
8 // Check if any of the modified fields have invalid values
9 if ('full_name' in row && !row.full_name?.trim()) return true;
10 if ('email' in row && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email || '')) return true;
11 if ('price' in row && (row.price < 0 || row.price === null)) return true;
12 return false;
13});
14
15if (invalidRows.length > 0) {
16 utils.showNotification({
17 title: 'Validation failed',
18 description: `${invalidRows.length} row${invalidRows.length > 1 ? 's have' : ' has'} invalid values. Please fix highlighted cells.`,
19 notificationType: 'error',
20 });
21 return;
22}
23
24// Proceed with save...

Expected result: Attempting to save rows with empty required fields shows a validation error and does not proceed to the UPDATE query.

Complete working example

JS Query: saveChanges (complete)
1// saveChanges — validates, bulk-updates changesetArray, and handles new rows
2// Trigger: Save Changes button click
3// Requires: updateSingleRow (SQL), insertSingleRow (SQL), getTableData (SQL)
4
5const changes = table1.changesetArray;
6const newRows = table1.newRows || [];
7const totalOperations = changes.length + newRows.length;
8
9if (totalOperations === 0) {
10 utils.showNotification({
11 title: 'No changes',
12 description: 'Make edits to rows before saving.',
13 notificationType: 'info',
14 });
15 return;
16}
17
18// Validate changed rows
19const invalidRows = changes.filter(row => {
20 if ('full_name' in row && !row.full_name?.trim()) return true;
21 if ('email' in row && row.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email)) return true;
22 return false;
23});
24
25// Validate new rows
26const invalidNewRows = newRows.filter(row => {
27 return !row.full_name?.trim() || !row.email?.trim();
28});
29
30if (invalidRows.length > 0 || invalidNewRows.length > 0) {
31 utils.showNotification({
32 title: 'Validation failed',
33 description: `${invalidRows.length + invalidNewRows.length} row(s) have missing required fields.`,
34 notificationType: 'error',
35 });
36 return;
37}
38
39try {
40 // Run updates and inserts in parallel
41 await Promise.all([
42 ...changes.map(row =>
43 updateSingleRow.trigger({
44 additionalScope: {
45 id: row.id,
46 fullName: row.full_name ?? null,
47 email: row.email ?? null,
48 status: row.status ?? null,
49 }
50 })
51 ),
52 ...newRows.map(row =>
53 insertSingleRow.trigger({
54 additionalScope: {
55 fullName: row.full_name,
56 email: row.email,
57 status: row.status || 'active',
58 }
59 })
60 ),
61 ]);
62
63 utils.showNotification({
64 title: 'Saved successfully',
65 description: `${changes.length} updated, ${newRows.length} inserted.`,
66 notificationType: 'success',
67 });
68
69 await getTableData.trigger();
70
71} catch (err) {
72 utils.showNotification({
73 title: 'Save failed',
74 description: err.message,
75 notificationType: 'error',
76 });
77 throw err;
78}

Common mistakes

Why it's a problem: Running the UPDATE for every row in the table instead of only rows in changesetArray — updates rows that were not changed and causes unnecessary database writes

How to avoid: Iterate only over table1.changesetArray, not table1.data. changesetArray contains only the rows that the user actually modified.

Why it's a problem: Not refreshing the table query after saving — the table shows the user's typed values instead of the canonical database values, including any server-side modifications

How to avoid: After a successful save, always call await getTableData.trigger() to reload the data from the database into the table.

Why it's a problem: Expecting changesetArray to include all column values for a modified row — it only includes the primary key and the columns that were actually changed

How to avoid: When building the UPDATE SQL, use COALESCE({{ changedColumnValue }}, existing_column) to default to the existing database value for columns not present in the changeset.

Why it's a problem: Trying to use a transformer to process changesetArray and trigger the update — transformers are read-only and cannot trigger queries

How to avoid: All mutation logic must be in a JS Query. Transformers can only compute derived values from existing state, not trigger side effects.

Best practices

  • Never make primary key (id) columns editable — this prevents accidental data corruption from users editing record identifiers
  • Show the pending change count in the Save button label: {{ 'Save Changes (' + table1.changesetArray.length + ')' }}
  • Add a Discard Changes button that calls the Table component's discardChanges action to let users back out of unwanted edits
  • Use COALESCE in the UPDATE SQL to only overwrite columns that were actually changed — passing null preserves the existing database value
  • Run multiple row updates in parallel with Promise.all() for better performance on bulk saves
  • Validate changesetArray rows before submitting — check required fields and data types to prevent database constraint errors
  • Always refresh the data query after saving to display the canonical database values (including server-side defaults like updated_at)

Still stuck?

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

ChatGPT Prompt

I have a Retool Table component named table1 showing customers (id, full_name, email, status, price). I want users to be able to edit cells directly in the table and save all changes at once. Show me: (1) how to enable editable columns in the Inspector for full_name (Text), email (Text), status (Select with active/inactive), price (Number), (2) the structure of table1.changesetArray with an example, (3) a JS Query named saveChanges that validates required fields and runs parallel UPDATE queries for each changed row, (4) a SQL query named updateSingleRow using COALESCE for the PostgreSQL UPDATE.

Retool Prompt

I enabled editable columns on my Retool table. How do I access the pending changes that users have made but not yet saved? What is table1.changesetArray and what does it look like? How do I disable the Save button when there are no pending changes?

Frequently asked questions

Does table1.changesetArray include the original values or just the new values for changed cells?

changesetArray includes the row's primary key and only the columns that changed with their new values. It does not include unchanged columns or the original pre-edit values. If you need to compare old vs new values for auditing, you would need to store the original data separately in a Temporary State before users start editing.

Can I automatically save changes when a user finishes editing a cell, without a Save button?

Yes — add a Row change event handler to the Table component (Inspector → Interaction → Row change). Set it to trigger the updateSingleRow query directly with the changed row's data. This creates an auto-save-on-edit UX but removes the ability for users to discard changes. Use this pattern for high-trust internal tools where immediate persistence is desired.

How do I highlight rows that have unsaved changes in the table?

In the Table Inspector → Columns, add a calculated column or use Row Color. Set the Row Color expression to check if the current row's ID appears in table1.changesetArray: {{ table1.changesetArray.some(r => r.id === currentRow.id) ? '#fffbeb' : 'transparent' }}. This highlights edited rows in yellow until they are saved.

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.