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

How to Use Custom Scripts in Retool

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.

What you'll learn

  • How to create and structure JS Queries in Retool for complex business logic
  • How to use async/await to trigger other queries programmatically with query.trigger()
  • How to pass parameters to queries using additionalScope
  • How to return values from JS Queries and use them in downstream components
  • How to chain multiple queries sequentially in a JS script
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate8 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
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.

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

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.

Prerequisites

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

typescript
1// JS Query: computeDashboardStats
2const orders = getOrders.data || [];
3const customers = getCustomers.data || [];
4
5// Compute statistics
6const totalRevenue = orders.reduce((sum, o) => sum + (o.total_amount || 0), 0);
7const avgOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
8const pendingCount = orders.filter(o => o.status === 'pending').length;
9const activeCustomers = customers.filter(c => c.is_active).length;
10
11// Return an object that components can bind to
12return {
13 totalRevenue: totalRevenue.toFixed(2),
14 avgOrderValue: avgOrderValue.toFixed(2),
15 pendingOrders: pendingCount,
16 activeCustomers
17};
18
19// In Statistic components:
20// Value: {{ computeDashboardStats.data.totalRevenue }}
21// 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.

typescript
1// JS Query: refreshAfterUpdate
2
3// Step 1: Run the update mutation
4await updateCustomer.trigger();
5
6// Step 2: After update completes, refresh the table data
7await getCustomers.trigger();
8
9// Step 3: Show success notification
10utils.showNotification({
11 title: 'Customer updated',
12 notificationType: 'success'
13});
14
15// Step 4: Close the modal
16await modal1.close();
17
18// IMPORTANT: Without await, the next line runs before trigger() finishes
19// WRONG: updateCustomer.trigger(); // Fire and forget — race condition!
20// 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.

typescript
1// SQL Query: 'updateOrderStatus' (defined separately)
2// SQL body: UPDATE orders SET status = {{ newStatus }} WHERE id = {{ orderId }}
3
4// JS Query: bulkUpdateOrders
5const selectedRows = table1.selectedRows; // Multiple selected rows
6
7if (!selectedRows || selectedRows.length === 0) {
8 utils.showNotification({ title: 'Select rows to update', notificationType: 'warning' });
9 return;
10}
11
12const targetStatus = statusDropdown.value;
13let updatedCount = 0;
14
15for (const row of selectedRows) {
16 await updateOrderStatus.trigger({
17 additionalScope: {
18 orderId: row.id,
19 newStatus: targetStatus
20 }
21 });
22 updatedCount++;
23 console.log(`Updated order ${row.id} to ${targetStatus}`);
24}
25
26utils.showNotification({
27 title: `${updatedCount} orders updated`,
28 notificationType: 'success'
29});
30
31await 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.

typescript
1// SEQUENTIAL — slow (adds up durations)
2await getCustomers.trigger();
3await getOrders.trigger();
4await getProducts.trigger();
5
6// PARALLEL — fast (runs simultaneously)
7await Promise.all([
8 getCustomers.trigger(),
9 getOrders.trigger(),
10 getProducts.trigger()
11]);
12
13// After all three complete, data is available
14console.log('Customers:', getCustomers.data?.length);
15console.log('Orders:', getOrders.data?.length);
16console.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.

typescript
1// JS Query: processImport
2let processedCount = 0;
3const rows = parseCSV.data; // Parsed CSV rows
4
5try {
6 for (const row of rows) {
7 // Validate each row before inserting
8 if (!row.email || !row.name) {
9 console.warn('Skipping invalid row:', row);
10 continue;
11 }
12
13 await insertUser.trigger({
14 additionalScope: { email: row.email, name: row.name }
15 });
16 processedCount++;
17 }
18
19 utils.showNotification({
20 title: `Import complete: ${processedCount} rows`,
21 notificationType: 'success'
22 });
23
24 await getUsers.trigger(); // Refresh table
25
26} catch (err) {
27 utils.showNotification({
28 title: `Import failed after ${processedCount} rows`,
29 description: err.message,
30 notificationType: 'error'
31 });
32 console.error('Import error:', err);
33}

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

Complete working example

JS Query: orchestrateOrderWorkflow
1// Full multi-step order processing workflow
2// Demonstrates: validation, sequential triggers, additionalScope, error handling
3
4const order = table1.selectedRow.data;
5const newStatus = statusSelect.value;
6const note = noteInput.value;
7
8// === Step 1: Input validation ===
9if (!order) {
10 utils.showNotification({ title: 'Select an order', notificationType: 'warning' });
11 return;
12}
13
14if (!newStatus) {
15 utils.showNotification({ title: 'Select a status', notificationType: 'warning' });
16 return;
17}
18
19if (order.status === newStatus) {
20 utils.showNotification({ title: 'Status is already ' + newStatus, notificationType: 'info' });
21 return;
22}
23
24// === Step 2: Set loading state ===
25await isProcessing.setValue(true);
26
27try {
28 // === Step 3: Update order status ===
29 await updateOrderStatus.trigger({
30 additionalScope: {
31 orderId: order.id,
32 newStatus,
33 updatedAt: new Date().toISOString()
34 }
35 });
36
37 // === Step 4: Log audit entry ===
38 await createAuditLog.trigger({
39 additionalScope: {
40 entityType: 'order',
41 entityId: order.id,
42 action: `status_changed_to_${newStatus}`,
43 note: note || '',
44 userId: current_user.id
45 }
46 });
47
48 // === Step 5: Send notification if status is 'shipped' ===
49 if (newStatus === 'shipped') {
50 await sendShipmentEmail.trigger({
51 additionalScope: { orderId: order.id, customerEmail: order.customer_email }
52 });
53 }
54
55 // === Step 6: Refresh data and notify ===
56 await getOrders.trigger();
57
58 utils.showNotification({
59 title: 'Order updated',
60 description: `Order #${order.id} is now ${newStatus}`,
61 notificationType: 'success'
62 });
63
64 await modal1.close();
65
66} catch (err) {
67 console.error('Workflow failed:', err);
68 utils.showNotification({
69 title: 'Update failed',
70 description: err.message,
71 notificationType: 'error'
72 });
73} finally {
74 // Always clear loading state, even on error
75 await isProcessing.setValue(false);
76}

Common mistakes

Why it's a problem: Using a transformer for a script that needs to trigger other queries

How to avoid: Transformers are read-only — they cannot call query.trigger() or state.setValue(). Convert the transformer to a JS Query to enable side effects.

Why it's a problem: Calling query.trigger() without await and then immediately reading query.data

How to avoid: Always await query.trigger() before accessing the query's .data. Without await, .data still holds the value from the previous run.

Why it's a problem: Using sequential await for independent queries that could run in parallel

How to avoid: Replace sequential await calls with await Promise.all([query1.trigger(), query2.trigger()]). This runs them simultaneously and reduces total execution time.

Why it's a problem: Event handlers on components run in parallel when multiple handlers exist

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

Still stuck?

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

ChatGPT Prompt

I am building a Retool app and need to write a JavaScript Query that: (1) validates form inputs before submitting, (2) sequentially triggers three SQL queries (create, audit log, refresh), (3) passes different parameters to each query using additionalScope, (4) shows a success notification after all queries complete, and (5) handles errors with a catch block. Show me the complete JS Query with proper async/await patterns.

Retool Prompt

In my Retool app, write a JS Query named 'bulkUpdateStatus' that: (1) reads table1.selectedRows (multiple selected rows), (2) loops through each row, (3) triggers a SQL query named 'updateRecord' with additionalScope containing the row's id and statusDropdown.value as the new status, (4) shows a progress count notification when done, and (5) refreshes the table with getRecords.trigger().

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.

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.