# How to Create Dynamic Forms in Retool

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

## TL;DR

Dynamic forms in Retool use the Hidden property on each component with a {{ }} boolean expression referencing other component values. Set a field's Hidden to {{ select1.value !== 'US' }} to show it only when select1 equals 'US'. Combine with dependent query triggers on Change event handlers to load child options on demand.

## Building Conditional Field Logic in Retool Forms

Dynamic forms change their visible fields based on what a user has already entered. In Retool, this is accomplished through the Hidden property available on every component. Instead of hiding an entire container, you write a short {{ }} expression referencing another component's value — when the expression evaluates to true, the field disappears; when false, it appears.

This tutorial focuses on the conditional-fields pattern: showing and hiding inputs on a single page based on upstream selections. You will build a form where a 'Customer Type' select drives whether business-specific fields appear, and where a Country dropdown loads state/region options from a filtered query. All interactions stay on one page — for multi-page wizard flows, see the multi-step-forms tutorial.

You will work with the Form component's built-in layout, Select components, Text Input components, the Hidden property in the Inspector's Layout section, and event handler chaining to re-run queries when parent field values change.

## Before you start

- A Retool app with at least one resource (database or REST API) configured
- Familiarity with adding components from the component panel
- Basic understanding of {{ }} expression syntax in the Inspector
- A data source that can return country and state/region lists (or use static JSON)

## Step-by-step guide

### 1. Add a Form component and set its layout

Drag a Form component from the component panel onto the canvas. In the Inspector's General section, set the Form's Title to 'Customer Registration'. The Form component acts as a logical grouping — it exposes form1.data (all child input values as an object) and form1.invalid (boolean) which you will use later for the submit button. Resize the form to span the full working width.

**Expected result:** A Form component named form1 appears on the canvas with a default Submit button.

### 2. Add a Customer Type Select and configure static options

Drag a Select component inside the Form. Rename it to customerTypeSelect in the Inspector. In General → Options, choose Manual and add two options: label 'Individual', value 'individual' and label 'Business', value 'business'. Set Placeholder to 'Select customer type'. This select will be the gating condition for all business-specific fields below.

**Expected result:** A dropdown showing 'Individual' and 'Business' options appears inside the form.

### 3. Add business-only fields and set their Hidden property

Add a Text Input named companyNameInput with label 'Company Name', and a Number Input named vatNumberInput with label 'VAT Number'. For each component, scroll to the Inspector's Layout section and find the Hidden toggle. Click the {{ fx }} icon to switch to expression mode. Enter: {{ customerTypeSelect.value !== 'business' }}. This hides the field whenever the customer type is not 'business'. Repeat for both business-specific inputs.

```
// Hidden property expression for business-only fields
{{ customerTypeSelect.value !== 'business' }}

// For a field that shows only for individuals:
{{ customerTypeSelect.value !== 'individual' }}
```

**Expected result:** Company Name and VAT Number fields disappear when 'Individual' is selected and reappear when 'Business' is selected.

### 4. Create a Country dropdown with dynamic options from a query

Add a Select component named countrySelect with label 'Country'. Create a new Resource Query named getCountries that returns a list of country records (id, name). Set the Select's Options Source to '{{ getCountries.data }}', Option Label to '{{ item.name }}', and Option Value to '{{ item.id }}'. Run getCountries on page load by enabling 'Run on page load' in the query's Settings tab.

```
-- SQL query: getCountries
SELECT id, name FROM countries ORDER BY name ASC;
```

**Expected result:** The Country dropdown populates with country names from your database on page load.

### 5. Create a dependent State/Region query and wire it to Country changes

Create a second Resource Query named getStates. Add a parameter to filter by the selected country: {{ countrySelect.value }}. In the query SQL, use that parameter. Back on countrySelect, go to Inspector → Interaction → Event Handlers. Add a handler: Event = 'Change', Action = 'Trigger query', Query = 'getStates'. Also add a second Change handler that calls stateSelect.resetValue() so stale selections clear when the country changes.

```
-- SQL query: getStates
SELECT id, name FROM states
WHERE country_id = {{ countrySelect.value }}
ORDER BY name ASC;
```

**Expected result:** When a country is selected, getStates runs and populates the State dropdown with the correct regions.

### 6. Add a State Select with a Hidden guard for countries without states

Add a Select component named stateSelect with label 'State / Region'. Set its Options to {{ getStates.data }}, label to {{ item.name }}, value to {{ item.id }}. In Layout → Hidden, enter: {{ getStates.data.length === 0 }}. This hides the state field entirely when the selected country has no state records, avoiding a confusing empty dropdown.

```
// Hidden expression on stateSelect
{{ getStates.data.length === 0 }}

// Options configuration
// Options source: {{ getStates.data }}
// Option label: {{ item.name }}
// Option value: {{ item.id }}
```

**Expected result:** State dropdown appears only for countries that have state records, disappears for others.

### 7. Wire the Submit button to read form1.data

Add a JS Query named submitForm. In the query body, read the consolidated form data from form1.data and call your save API or mutation. In the Form component's Inspector, set the Submit button's event handler to trigger submitForm. Add a 'Only run when' condition to the submitForm query: {{ !form1.invalid }} so it refuses to execute if required fields are empty.

```
// JS Query: submitForm
const payload = {
  customerType: form1.data.customerTypeSelect,
  country: form1.data.countrySelect,
  state: form1.data.stateSelect,
  // business fields will be undefined for individuals — filter them
  ...(form1.data.customerTypeSelect === 'business' && {
    companyName: form1.data.companyNameInput,
    vatNumber: form1.data.vatNumberInput,
  }),
};

return await saveCustomer.trigger({ additionalScope: { payload } });
```

**Expected result:** Clicking Submit runs submitForm only when the form passes validation, passing only relevant fields.

### 8. Test all conditional paths and verify Hidden logic

Preview the app (Ctrl+Shift+P or click Preview). Test each path: select Individual — business fields should vanish; select Business — they should appear. Pick a country with states — state dropdown appears; pick one without — it hides. Open the State Inspector in the Debug Panel (bottom of editor) and verify component values update correctly. Check form1.data in the State Inspector to confirm the payload structure matches expectations.

**Expected result:** All conditional paths work correctly; form1.data contains the right set of fields based on the active selection.

## Complete code example

File: `JS Query: submitForm`

```javascript
// submitForm — reads form1.data, filters to relevant fields, submits
// Set 'Only run when': {{ !form1.invalid }}

const data = form1.data;
const isBusinessCustomer = data.customerTypeSelect === 'business';

// Build payload — only include business fields when applicable
const payload = {
  customer_type: data.customerTypeSelect,
  country_id: data.countrySelect,
  state_id: data.stateSelect || null,
  email: data.emailInput,
};

if (isBusinessCustomer) {
  payload.company_name = data.companyNameInput;
  payload.vat_number = data.vatNumberInput;
}

try {
  await insertCustomer.trigger({
    additionalScope: { payload }
  });
  utils.showNotification({
    title: 'Success',
    description: 'Customer saved successfully.',
    notificationType: 'success',
  });
  form1.reset();
} catch (err) {
  utils.showNotification({
    title: 'Error',
    description: err.message,
    notificationType: 'error',
  });
  throw err;
}
```

## Common mistakes

- **Inverted Hidden logic — writing {{ customerTypeSelect.value === 'business' }} instead of {{ customerTypeSelect.value !== 'business' }} causes the field to show for the wrong selection** — undefined Fix: Read Hidden as 'hide when this is true'. A field that should appear for business should have Hidden: {{ customerTypeSelect.value !== 'business' }}
- **Forgetting to reset child selects when the parent changes — stateSelect retains its previous value after changing the country, causing form1.data to contain a stale state ID** — undefined Fix: Add a second event handler on countrySelect Change that calls stateSelect.resetValue() before or after triggering getStates
- **Reading form1.data inside a transformer instead of a JS Query, then trying to call a mutation from it** — undefined Fix: Transformers are read-only and cannot trigger queries or set state. Move the submission logic to a JS Query and call the mutation with await saveQuery.trigger()
- **Using a static options array on dependent dropdowns instead of a filtered query, resulting in all states always visible** — undefined Fix: Create a separate getStates query with {{ countrySelect.value }} as a parameter, and trigger it from countrySelect's Change event handler

## Best practices

- Use the Form component wrapper so form1.data aggregates all child inputs into one object — avoids manually reading each component value
- Always set Hidden expressions to evaluate truthy for hidden, not for visible — Hidden: true = the component is invisible
- Reset dependent selects via resetValue() in the parent's Change event handler to prevent stale selections surviving country changes
- Guard the submit query with 'Only run when: {{ !form1.invalid }}' instead of managing disabled state manually on the button
- Keep conditional fields in dedicated containers (Container or Form Section components) if many fields share the same condition — one Hidden expression on the container is cleaner than individual ones
- Use the State Inspector panel during development to watch component values update in real time as you test conditional paths
- Avoid nesting Hidden expressions more than two levels deep — compound conditions become hard to debug; use a Temporary State variable to encode complex state instead

## Frequently asked questions

### Can I show or hide an entire section of fields at once instead of field by field?

Yes. Wrap related fields in a Container component and set the Hidden property on the Container. All child components inherit the visibility, so you only need one Hidden expression rather than one per field.

### Does hiding a field remove its value from form1.data?

No — hidden components still contribute their current value to form1.data. If you need to exclude a hidden field's value from the submission payload, filter it out explicitly in your JS Query based on the gating condition, as shown in the submitForm code example above.

### How do I make a field required only when it is visible?

Set the component's Required property to an expression that mirrors the visibility condition: {{ customerTypeSelect.value === 'business' }}. This way, the field is only required when it is shown. form1.invalid will still incorporate this conditional requirement correctly.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-create-dynamic-forms-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-create-dynamic-forms-in-retool
