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.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Intermediate |
| Time required | 20-25 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 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
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.
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.
1// JS Query: computeDashboardStats2const orders = getOrders.data || [];3const customers = getCustomers.data || [];45// Compute statistics6const 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;1011// Return an object that components can bind to12return {13 totalRevenue: totalRevenue.toFixed(2),14 avgOrderValue: avgOrderValue.toFixed(2),15 pendingOrders: pendingCount,16 activeCustomers17};1819// 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.
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.
1// JS Query: refreshAfterUpdate23// Step 1: Run the update mutation4await updateCustomer.trigger();56// Step 2: After update completes, refresh the table data7await getCustomers.trigger();89// Step 3: Show success notification10utils.showNotification({11 title: 'Customer updated',12 notificationType: 'success'13});1415// Step 4: Close the modal16await modal1.close();1718// IMPORTANT: Without await, the next line runs before trigger() finishes19// 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.
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.
1// SQL Query: 'updateOrderStatus' (defined separately)2// SQL body: UPDATE orders SET status = {{ newStatus }} WHERE id = {{ orderId }}34// JS Query: bulkUpdateOrders5const selectedRows = table1.selectedRows; // Multiple selected rows67if (!selectedRows || selectedRows.length === 0) {8 utils.showNotification({ title: 'Select rows to update', notificationType: 'warning' });9 return;10}1112const targetStatus = statusDropdown.value;13let updatedCount = 0;1415for (const row of selectedRows) {16 await updateOrderStatus.trigger({17 additionalScope: {18 orderId: row.id,19 newStatus: targetStatus20 }21 });22 updatedCount++;23 console.log(`Updated order ${row.id} to ${targetStatus}`);24}2526utils.showNotification({27 title: `${updatedCount} orders updated`,28 notificationType: 'success'29});3031await getOrders.trigger();Expected result: Each selected row is updated one by one using the same SQL query with different parameters injected via additionalScope.
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.
1// SEQUENTIAL — slow (adds up durations)2await getCustomers.trigger();3await getOrders.trigger();4await getProducts.trigger();56// PARALLEL — fast (runs simultaneously)7await Promise.all([8 getCustomers.trigger(),9 getOrders.trigger(),10 getProducts.trigger()11]);1213// After all three complete, data is available14console.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.
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.
1// JS Query: processImport2let processedCount = 0;3const rows = parseCSV.data; // Parsed CSV rows45try {6 for (const row of rows) {7 // Validate each row before inserting8 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 table25 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
1// Full multi-step order processing workflow2// Demonstrates: validation, sequential triggers, additionalScope, error handling34const order = table1.selectedRow.data;5const newStatus = statusSelect.value;6const note = noteInput.value;78// === Step 1: Input validation ===9if (!order) {10 utils.showNotification({ title: 'Select an order', notificationType: 'warning' });11 return;12}1314if (!newStatus) {15 utils.showNotification({ title: 'Select a status', notificationType: 'warning' });16 return;17}1819if (order.status === newStatus) {20 utils.showNotification({ title: 'Status is already ' + newStatus, notificationType: 'info' });21 return;22}2324// === Step 2: Set loading state ===25await isProcessing.setValue(true);2627try {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.id45 }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 error75 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation