# How to Use Custom Scripts in Retool

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

## TL;DR

Custom scripts in Retool are JavaScript Queries that run arbitrary JS logic, call other queries with await query.trigger(), and return data that components can bind to. Unlike transformers, JS Queries can trigger other queries, update state variables, and perform async operations. Use additionalScope to pass runtime parameters to triggered queries.

## Writing JS Query Scripts in Retool

Custom scripts in Retool are JavaScript Queries — not to be confused with transformers, which are read-only. JS Queries are where you write imperative logic: conditionally trigger different queries based on form values, build objects from multiple data sources, loop through records and batch-update them, or orchestrate multi-step workflows.

The key difference from transformers is that JS Queries can call await query.trigger() to programmatically execute any other query in the app, call state variable methods like await state1.setValue(), and call utility functions like utils.showNotification(). They also return a value that becomes the query's .data property, which components can bind to.

This tutorial covers creating JS Queries, triggering other queries with parameters, building multi-step orchestration flows, and returning structured data.

## Before you start

- A Retool account (Cloud or Self-hosted)
- Basic JavaScript knowledge (functions, async/await, arrays, objects)
- A Retool app with at least one SQL or REST query defined
- Understanding of the Code panel and how to create queries

## Step-by-step guide

### 1. Create a JavaScript Query in the Code panel

In the Retool editor, open the Code panel from the left sidebar. Click the + button and select JavaScript Query (not SQL or REST). Give it a descriptive name like processOrderSubmission or fetchAndMergeData. The query editor opens with a blank JS editor. By default, JS Queries run when triggered (not automatically on page load) — you can change this in the query's General settings.

**Expected result:** A new JS Query appears in the Code panel. The editor shows a blank JavaScript editor with no syntax restrictions.

### 2. Write a basic script that returns data

A JS Query returns a value that becomes its .data property. Any component can bind to {{ jsQuery1.data }}. Return any JavaScript value — a number, string, array, or object. This makes JS Queries powerful as computation and transformation nodes that produce structured data for your UI.

```
// JS Query: computeDashboardStats
const orders = getOrders.data || [];
const customers = getCustomers.data || [];

// Compute statistics
const totalRevenue = orders.reduce((sum, o) => sum + (o.total_amount || 0), 0);
const avgOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
const pendingCount = orders.filter(o => o.status === 'pending').length;
const activeCustomers = customers.filter(c => c.is_active).length;

// Return an object that components can bind to
return {
  totalRevenue: totalRevenue.toFixed(2),
  avgOrderValue: avgOrderValue.toFixed(2),
  pendingOrders: pendingCount,
  activeCustomers
};

// In Statistic components:
// Value: {{ computeDashboardStats.data.totalRevenue }}
// Value: {{ computeDashboardStats.data.pendingOrders }}
```

**Expected result:** After triggering the query, components bound to {{ computeDashboardStats.data.totalRevenue }} etc. display the computed values.

### 3. Trigger another query programmatically

Use await queryName.trigger() to programmatically execute any other query defined in your app. This works for SQL queries, REST API queries, and other JS Queries. Always await the trigger call — it returns a Promise that resolves with the query's result. Without await, the script continues before the query finishes, and .data will be null.

```
// JS Query: refreshAfterUpdate

// Step 1: Run the update mutation
await updateCustomer.trigger();

// Step 2: After update completes, refresh the table data
await getCustomers.trigger();

// Step 3: Show success notification
utils.showNotification({
  title: 'Customer updated',
  notificationType: 'success'
});

// Step 4: Close the modal
await modal1.close();

// IMPORTANT: Without await, the next line runs before trigger() finishes
// WRONG: updateCustomer.trigger(); // Fire and forget — race condition!
// CORRECT: await updateCustomer.trigger();
```

**Expected result:** The queries execute in order, each completing before the next begins. The table refreshes with updated data after the mutation.

### 4. Pass parameters to queries using additionalScope

When you trigger a query that uses {{ }} parameter bindings, you can override those bindings at trigger time using additionalScope. Pass an object where each key is the name of a variable you want to inject. The triggered query can reference these via {{ variableName }} in its body. This is essential for loops where you need to call the same query with different values each iteration.

```
// SQL Query: 'updateOrderStatus' (defined separately)
// SQL body: UPDATE orders SET status = {{ newStatus }} WHERE id = {{ orderId }}

// JS Query: bulkUpdateOrders
const selectedRows = table1.selectedRows; // Multiple selected rows

if (!selectedRows || selectedRows.length === 0) {
  utils.showNotification({ title: 'Select rows to update', notificationType: 'warning' });
  return;
}

const targetStatus = statusDropdown.value;
let updatedCount = 0;

for (const row of selectedRows) {
  await updateOrderStatus.trigger({
    additionalScope: {
      orderId: row.id,
      newStatus: targetStatus
    }
  });
  updatedCount++;
  console.log(`Updated order ${row.id} to ${targetStatus}`);
}

utils.showNotification({
  title: `${updatedCount} orders updated`,
  notificationType: 'success'
});

await getOrders.trigger();
```

**Expected result:** Each selected row is updated one by one using the same SQL query with different parameters injected via additionalScope.

### 5. Run queries in parallel with Promise.all()

When you need to fetch data from multiple independent sources simultaneously, use Promise.all() instead of sequential awaits. Sequential awaiting adds the durations together; parallel execution takes only as long as the slowest query. Use Promise.all() whenever the queries do not depend on each other's results.

```
// SEQUENTIAL — slow (adds up durations)
await getCustomers.trigger();
await getOrders.trigger();
await getProducts.trigger();

// PARALLEL — fast (runs simultaneously)
await Promise.all([
  getCustomers.trigger(),
  getOrders.trigger(),
  getProducts.trigger()
]);

// After all three complete, data is available
console.log('Customers:', getCustomers.data?.length);
console.log('Orders:', getOrders.data?.length);
console.log('Products:', getProducts.data?.length);
```

**Expected result:** All three queries execute simultaneously and the script continues after all three complete, significantly faster than sequential execution.

### 6. Handle errors in multi-step scripts

In a multi-step script, a failure in an early step should prevent later steps from running. Wrap your entire script in try/catch. For partial success scenarios (e.g., some batch updates succeeded before a failure), track progress and show the user what completed before the error.

```
// JS Query: processImport
let processedCount = 0;
const rows = parseCSV.data; // Parsed CSV rows

try {
  for (const row of rows) {
    // Validate each row before inserting
    if (!row.email || !row.name) {
      console.warn('Skipping invalid row:', row);
      continue;
    }
    
    await insertUser.trigger({
      additionalScope: { email: row.email, name: row.name }
    });
    processedCount++;
  }
  
  utils.showNotification({
    title: `Import complete: ${processedCount} rows`,
    notificationType: 'success'
  });
  
  await getUsers.trigger(); // Refresh table
  
} catch (err) {
  utils.showNotification({
    title: `Import failed after ${processedCount} rows`,
    description: err.message,
    notificationType: 'error'
  });
  console.error('Import error:', err);
}
```

**Expected result:** The import runs until completion or the first unrecoverable error. The user sees how many rows succeeded before the failure.

## Complete code example

File: `JS Query: orchestrateOrderWorkflow`

```javascript
// Full multi-step order processing workflow
// Demonstrates: validation, sequential triggers, additionalScope, error handling

const order = table1.selectedRow.data;
const newStatus = statusSelect.value;
const note = noteInput.value;

// === Step 1: Input validation ===
if (!order) {
  utils.showNotification({ title: 'Select an order', notificationType: 'warning' });
  return;
}

if (!newStatus) {
  utils.showNotification({ title: 'Select a status', notificationType: 'warning' });
  return;
}

if (order.status === newStatus) {
  utils.showNotification({ title: 'Status is already ' + newStatus, notificationType: 'info' });
  return;
}

// === Step 2: Set loading state ===
await isProcessing.setValue(true);

try {
  // === Step 3: Update order status ===
  await updateOrderStatus.trigger({
    additionalScope: {
      orderId: order.id,
      newStatus,
      updatedAt: new Date().toISOString()
    }
  });
  
  // === Step 4: Log audit entry ===
  await createAuditLog.trigger({
    additionalScope: {
      entityType: 'order',
      entityId: order.id,
      action: `status_changed_to_${newStatus}`,
      note: note || '',
      userId: current_user.id
    }
  });
  
  // === Step 5: Send notification if status is 'shipped' ===
  if (newStatus === 'shipped') {
    await sendShipmentEmail.trigger({
      additionalScope: { orderId: order.id, customerEmail: order.customer_email }
    });
  }
  
  // === Step 6: Refresh data and notify ===
  await getOrders.trigger();
  
  utils.showNotification({
    title: 'Order updated',
    description: `Order #${order.id} is now ${newStatus}`,
    notificationType: 'success'
  });
  
  await modal1.close();
  
} catch (err) {
  console.error('Workflow failed:', err);
  utils.showNotification({
    title: 'Update failed',
    description: err.message,
    notificationType: 'error'
  });
} finally {
  // Always clear loading state, even on error
  await isProcessing.setValue(false);
}
```

## Common mistakes

- **Using a transformer for a script that needs to trigger other queries** — undefined Fix: Transformers are read-only — they cannot call query.trigger() or state.setValue(). Convert the transformer to a JS Query to enable side effects.
- **Calling query.trigger() without await and then immediately reading query.data** — undefined Fix: Always await query.trigger() before accessing the query's .data. Without await, .data still holds the value from the previous run.
- **Using sequential await for independent queries that could run in parallel** — undefined Fix: Replace sequential await calls with await Promise.all([query1.trigger(), query2.trigger()]). This runs them simultaneously and reduces total execution time.
- **Event handlers on components run in parallel when multiple handlers exist** — undefined Fix: If you have multiple event handlers on the same component event and need them to run sequentially, consolidate them into a single JS Query that chains the calls with await.

## Best practices

- Always await query.trigger() calls — without await, the script continues before the query completes, creating race conditions.
- Use additionalScope to pass runtime values to triggered queries rather than relying on live component bindings that may change during execution.
- Use Promise.all() for independent parallel queries — it is significantly faster than sequential await for queries that do not depend on each other.
- Wrap multi-step workflows in try/catch/finally — finally always runs and is ideal for clearing loading state variables.
- Remember transformers are read-only — they cannot call query.trigger() or setValue(). Move any logic that triggers queries into a JS Query.
- Return a meaningful value from JS Queries (not just side effects) so the query's .data property can be used for further binding.
- Use console.log checkpoints throughout long scripts to make debugging easier when something goes wrong mid-workflow.

## Frequently asked questions

### What is the difference between a JS Query and a transformer in Retool?

A transformer is read-only — it computes and returns a value derived from existing data but cannot trigger queries, call setValue(), or produce side effects. A JS Query can do all of that: trigger other queries, update state variables, navigate pages, and return data. Use transformers for pure data reshaping and JS Queries for anything with side effects.

### Can a JS Query call another JS Query in Retool?

Yes. You can trigger any query type — including other JS Queries — using await jsQueryName.trigger(). This is useful for creating reusable logic modules. Just be careful of circular dependencies, which will cause infinite loops.

### How do I pass the current user's data to a triggered SQL query from a JS Query?

Use additionalScope: await myQuery.trigger({ additionalScope: { userId: current_user.id, userEmail: current_user.email } }). In the SQL query body, reference {{ userId }} and {{ userEmail }} as parameters. The current_user object is available directly in JS Queries without {{ }} syntax.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-use-custom-scripts-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-use-custom-scripts-in-retool
