Custom validators in Retool are JavaScript expressions entered in the Inspector's Custom Validation field. The expression must return an empty string (valid) or an error message string (invalid). Reference other components with {{ }} syntax. For async checks, the expression evaluates against already-loaded query data — it does not itself trigger queries.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Intermediate |
| Time required | 20-25 min |
| Compatibility | Retool Cloud and Self-hosted |
| Last updated | March 2026 |
Writing JavaScript Custom Validation Expressions in Retool
Retool's built-in validation rules (Required, Email, Min/Max Length) cover standard cases, but production forms often need rules the Inspector cannot express: confirm-password matching, business logic constraints, or uniqueness checks against your database. Custom Validation fields accept JavaScript expressions that integrate directly into the form's validation lifecycle.
A Custom Validation expression runs every time the component's value changes. It receives the component's current value as self and can reference any other component's value via {{ }}. If the expression returns an empty string, the field is valid. If it returns a non-empty string, that string becomes the inline error message and the component's .invalid property becomes true.
This tutorial builds three progressive examples: a password-confirm match validator, a cross-field numeric constraint, and an async-style uniqueness check that evaluates against pre-loaded query data. Note that Custom Validation expressions cannot themselves trigger queries — they evaluate synchronously against already-available data.
Prerequisites
- A Retool Form component with at least two input components for cross-field examples
- Familiarity with built-in validation rules from the how-to-use-validation-in-retool-forms tutorial
- Basic JavaScript knowledge (ternary operators, string comparisons)
- Optional: a query that loads existing records for uniqueness checking
Step-by-step guide
Locate the Custom Validation field in the Inspector
Select a Text Input component inside your form. In the Inspector, scroll to the Validation section. Below the built-in rule toggles (Required, Min Length, Max Length, Pattern), you will find a Custom Validation field. Click the {{ fx }} icon to switch it to JavaScript expression mode. This field evaluates a JavaScript expression and expects the return value to be either an empty string '' (valid) or an error string (invalid). The error string is displayed as inline red text below the field.
Expected result: The Custom Validation field accepts a JavaScript expression and shows a preview of the current return value in the Inspector.
Write a confirm-password match validator
Add two Text Input components inside the form: passwordInput (type: Password) and confirmPasswordInput (type: Password). On confirmPasswordInput, open the Validation section and enter this Custom Validation expression. The expression compares confirmPasswordInput's current value (self) against passwordInput.value and returns an error message if they differ. The self keyword refers to the current component's value in Custom Validation expressions.
1// Custom Validation on confirmPasswordInput:2{{ self !== passwordInput.value ? 'Passwords do not match' : '' }}34// With additional length check (combining with built-in Min Length):5{{ self !== passwordInput.value ? 'Passwords do not match' : '' }}6// Min Length: 8 (set as built-in rule — both rules must pass)Expected result: Typing a non-matching value in confirmPasswordInput shows 'Passwords do not match'. Matching values clear the error.
Build a cross-field numeric constraint validator
For a date range or budget allocation form, you may need to ensure one number is less than another. Add minValueInput and maxValueInput (Number Input components). On maxValueInput, add a Custom Validation expression that checks the relationship. This pattern works for any two-field comparison: start/end dates (using Date Pickers), minimum/maximum quantities, etc.
1// Custom Validation on maxValueInput:2{{ self <= minValueInput.value ? 'Maximum must be greater than minimum' : '' }}34// For date range validation on endDatePicker:5{{ self && startDatePicker.value && new Date(self) <= new Date(startDatePicker.value) 6 ? 'End date must be after start date' 7 : '' }}89// Budget allocation: sum of parts must not exceed total10{{ (budgetQ1.value + budgetQ2.value + budgetQ3.value + budgetQ4.value) > totalBudgetInput.value11 ? `Total allocation ${budgetQ1.value + budgetQ2.value + budgetQ3.value + budgetQ4.value} exceeds budget ${totalBudgetInput.value}`12 : '' }}Expected result: Setting maxValueInput to a value less than or equal to minValueInput shows the error message and sets form1.invalid = true.
Implement a uniqueness check against query data
For email or username uniqueness, load existing records with a query that runs on page load, then reference that query's data in the Custom Validation expression. Because Custom Validation expressions cannot trigger queries themselves, the uniqueness data must already be loaded. Create a query named getExistingEmails that returns a list of email records. On the emailInput, add a Custom Validation expression that checks whether the current value appears in that list.
1-- SQL query: getExistingEmails (Run on page load: ON)2SELECT LOWER(email) as email FROM users;34// Custom Validation on emailInput:5{{ 6 getExistingEmails.data.some(row => row.email === self.toLowerCase())7 ? 'This email address is already registered'8 : ''9}}1011// For case-insensitive username check:12{{ 13 getExistingUsernames.data.some(row => row.username.toLowerCase() === self.toLowerCase())14 ? 'Username already taken'15 : ''16}}Expected result: Entering an email that exists in the database shows 'This email address is already registered'. A new email clears the error.
Combine custom validation with built-in rules
Custom Validation stacks on top of built-in rules — a field is only valid when all built-in rules AND the custom expression pass. For emailInput, you can have Required = true, Pattern = Email (built-in), and also the uniqueness custom validation. The field will show the most recently triggered error. Set the order of severity: the built-in Email pattern validation will prevent the uniqueness check from running on malformed input since Retool short-circuits on the first failing rule.
Expected result: An empty email triggers the Required error. A malformed email triggers the Email pattern error. A valid but taken email triggers the uniqueness error.
Use the State Inspector to debug custom validation expressions
If a Custom Validation expression is not behaving as expected, open the Debug Panel at the bottom of the editor and click State Inspector. Find the component (e.g., emailInput) and expand its properties. You will see the current .value and .invalid state. You can also add a Text component to the canvas temporarily and set its Value to the raw expression to see what it returns: {{ getExistingEmails.data.some(row => row.email === emailInput.value.toLowerCase()) }}. This isolates whether the issue is the query data, the comparison logic, or the component reference.
1// Temporary debug Text component value:2// Paste this to see the raw expression result3{{ JSON.stringify(getExistingEmails.data.slice(0, 5)) }}45// Check if the comparison logic is correct:6{{ getExistingEmails.data.some(row => row.email === emailInput.value.toLowerCase()) }}Expected result: State Inspector shows emailInput.invalid = true when the expression returns a non-empty string.
Prevent form submission until custom validators pass
Custom validation integrates with form1.invalid automatically. Any component with a failing Custom Validation expression contributes to form1.invalid = true. Ensure the Submit button's Disabled property is set to {{ form1.invalid }} and optionally call form1.validate() at the start of the submit JS Query for defense-in-depth. The validate() call forces all error messages to display even if not yet touched.
1// JS Query: submitWithCustomValidation2const isValid = form1.validate();34if (!isValid) {5 utils.showNotification({6 title: 'Please fix the errors',7 description: 'Check all highlighted fields before submitting.',8 notificationType: 'warning',9 });10 return;11}1213// Safe to submit — all built-in and custom validations passed14await createUser.trigger({15 additionalScope: {16 email: emailInput.value,17 username: usernameInput.value,18 password: passwordInput.value,19 }20});2122form1.reset();Expected result: The form will not submit while any custom validator returns a non-empty error string.
Complete working example
1// === CONFIRM PASSWORD ===2// On confirmPasswordInput → Custom Validation:3// self !== passwordInput.value ? 'Passwords do not match' : ''45// === CROSS-FIELD NUMERIC ===6// On maxValueInput → Custom Validation:7// self <= minValueInput.value ? 'Max must be greater than min' : ''89// === DATE RANGE ===10// On endDatePicker → Custom Validation:11// self && startDatePicker.value && new Date(self) <= new Date(startDatePicker.value)12// ? 'End date must be after start date'13// : ''1415// === EMAIL UNIQUENESS ===16// On emailInput → Custom Validation:17// getExistingEmails.data.some(row => row.email === self.toLowerCase())18// ? 'Email already registered'19// : ''2021// === USERNAME FORMAT + UNIQUENESS COMBINED ===22// On usernameInput → Custom Validation:23// (() => {24// if (!/^[a-z0-9_]{3,20}$/.test(self)) {25// return 'Username must be 3-20 lowercase letters, numbers, or underscores';26// }27// if (getExistingUsernames.data.some(r => r.username === self)) {28// return 'Username already taken';29// }30// return '';31// })()3233// IIFE pattern for multi-condition validators:34(()=> {35 if (!self) return '';36 if (!/^[a-z0-9_]{3,20}$/.test(self)) {37 return 'Username must be 3-20 lowercase letters, numbers, or underscores';38 }39 if (getExistingUsernames.data.some(r => r.username.toLowerCase() === self.toLowerCase())) {40 return 'Username already taken';41 }42 return '';43})()Common mistakes when creating Custom Validators in Retool Forms
Why it's a problem: Returning true/false from the Custom Validation expression instead of a string — false does not register as invalid in Retool's system
How to avoid: Always return an error string (non-empty) for invalid and an empty string '' for valid. Example: {{ self !== confirmPasswordInput.value ? 'Passwords do not match' : '' }}
Why it's a problem: Trying to call await query.trigger() inside a Custom Validation expression — Custom Validation is a synchronous expression and cannot await async operations
How to avoid: Pre-load the validation data with a query that runs on page load and reference query.data in the Custom Validation expression. For debounced checks, use a Change event handler on the input to trigger the query, then read its data reactively.
Why it's a problem: Transformers are used to compute the list of existing values for uniqueness checks and the developer tries to trigger a query update from within the transformer
How to avoid: Transformers are read-only — they cannot trigger queries or set state. Use the query directly and reference query.data in the validation expression, or compute the derived list in a standalone transformer that reads from query.data.
Why it's a problem: Custom validator references passwordInput.value but the component is named passwordField1 — the reference silently returns undefined causing the validator to always pass
How to avoid: Double-check component names in the Inspector (the Name field at the top of the Inspector panel, not the Label). Use the State Inspector to verify the component is accessible by the name you expect.
Best practices
- Return an empty string '' for valid state and a human-readable error string for invalid — never return a boolean, null, or undefined
- Use IIFE (immediately invoked function expression) syntax for multi-condition validators: (()=>{ ... })() to keep logic readable
- Load uniqueness-check data with 'Run on page load' on the query — do not attempt to trigger queries from within a Custom Validation expression
- For large datasets where loading all records is impractical, use a debounced Change event handler to run a count query, then reference the count result in the Custom Validation expression
- Stack built-in rules and custom validators together — built-in rules are evaluated first and provide efficient early failures before the custom expression runs
- Use self to reference the current component value inside Custom Validation expressions instead of the component name — it is more readable and less error-prone
- Test edge cases: empty string, null, leading/trailing whitespace, and case differences — normalize with .trim() and .toLowerCase() defensively
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a Retool registration form with these fields: emailInput (Text Input), usernameInput (Text Input), passwordInput (Password Text Input), confirmPasswordInput (Password Text Input). I have a query named getExistingUsers that returns an array of objects with email and username fields. Write the Custom Validation expressions for: (1) confirmPasswordInput to match passwordInput, (2) emailInput to check uniqueness against getExistingUsers.data, (3) usernameInput to enforce lowercase letters/numbers/underscores only (3-20 chars) AND check uniqueness. Include IIFE syntax for the multi-condition validator.
In my Retool form, I need to validate that endDatePicker is after startDatePicker. What do I write in endDatePicker's Custom Validation field? Also explain whether I should use the built-in Date validation or a Custom Validation expression for this cross-field check.
Frequently asked questions
Can I use async/await inside a Custom Validation expression?
No. Custom Validation expressions are synchronous. They cannot contain await, Promise resolution, or query triggers. To validate against live database data, pre-load the data with a query (Run on page load) and reference query.data in the expression, or use a debounced Change handler to trigger a query and read its result.
What happens when both a built-in rule and a Custom Validation expression fail at the same time?
Retool displays the error from the first failing rule. Built-in rules are evaluated before the Custom Validation expression. If Required fails, the Required error message shows. Once the user fixes that and triggers re-evaluation, the Custom Validation error becomes visible. This is why stacking rules is useful — it guides the user through errors progressively.
How do I access the value of a component inside a different container in a Custom Validation expression?
Use the component's name directly in the expression regardless of container nesting — Retool exposes all named components in the app's global scope. For example, {{ self !== passwordInput.value ? 'Mismatch' : '' }} works even if confirmPasswordInput and passwordInput are in different nested containers, as long as both names are unique in the app.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation