# How to Implement Multi-Step Forms in Retool

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

## TL;DR

Multi-step forms in Retool use a Tabbed Container where each tab is a form step. A Temporary State variable (currentStep) tracks which tab is active. Next/Back buttons update currentStep with setValue(), and form validation gates advancement. On the final step, collect data from all steps' Form components and submit in one JS Query.

## Building a Tabbed Multi-Step Form with Step Validation in Retool

Multi-step forms guide users through a complex data entry process by breaking it into sequential steps. Retool does not have a native multi-step form component, but the pattern is achievable using a Tabbed Container (to hold each step) plus a Temporary State variable (to track which step is active) plus step-level Form components (to enable per-step validation).

This tutorial covers the Tabbed Container approach, which is different from the Wizard component approach in the wizard-interfaces tutorial. The Tabbed Container gives you more control over layout and validation logic at the cost of slightly more setup. Use this pattern when you need full control over step transitions and custom validation logic between steps.

You will build a 3-step registration form: Step 1 Personal Info, Step 2 Address, Step 3 Review & Submit. Each step has its own Form component for per-step validation. A Temporary State object accumulates data across steps. Back button restores previously entered values. Final step submits everything in one query.

## Before you start

- Familiarity with Temporary State variables and setValue() — see the state-variables tutorial
- Understanding of Form component validation and form1.validate()
- A Retool app with a database resource configured for the final submission
- Basic knowledge of JS Queries and await syntax

## Step-by-step guide

### 1. Create a Tabbed Container and disable tab header clicks

Drag a Tabbed Container component onto the canvas. Add three tabs by clicking '+ Add tab': name them 'Step 1: Personal Info', 'Step 2: Address', and 'Step 3: Review'. In the Inspector, find the 'Tab bar clickable' toggle and disable it. This prevents users from jumping between tabs by clicking the tab headers — navigation should only happen via your Next and Back buttons. Users should always follow the validated sequence.

**Expected result:** A Tabbed Container with 3 tabs appears. Clicking tab headers does nothing — they are display-only.

### 2. Create a Temporary State variable to track the current step

Open the State tab in the left panel (or use the + button in the bottom Code panel) and create a new Temporary State variable named currentStep with a default value of 0. This integer represents the active tab index (0-based). You will drive the Tabbed Container's Selected Tab Index property from this variable and advance/decrement it with Next/Back button click handlers.

```
// Temporary State: currentStep
// Default value: 0
// Type: Number

// Bind Tabbed Container's Selected Tab Index in Inspector:
{{ currentStep.value }}

// The container shows tab 0 (Step 1) on load.
```

**Expected result:** tabbedContainer1 shows Step 1 on load. currentStep.value is 0 in the State Inspector.

### 3. Add a Form component to each tab with its input fields

Inside Tab 1, drag a Form component named step1Form. Add fields: firstNameInput, lastNameInput, emailInput with appropriate validation rules (Required, Email pattern). Inside Tab 2, add step2Form with streetInput, cityInput, countrySelect. Inside Tab 3, add a read-only summary view (use Text components bound to the accumulated data). Each Form component enables per-step validation via stepNForm.validate().

**Expected result:** Each tab contains a Form component with appropriate labeled inputs and validation rules.

### 4. Create a formData Temporary State object to accumulate data

Create a second Temporary State variable named formData with a default value of {} (empty object). This object will store data from each step as the user advances. When the user completes Step 1 and clicks Next, you will call formData.setValue() to merge Step 1's data in. The accumulated formData persists as the user navigates between steps, so Back navigation does not erase earlier entries.

```
// Temporary State: formData
// Default value: {}

// After Step 1 completes, store the data:
await formData.setValue({
  ...formData.value,
  firstName: step1Form.data.firstNameInput,
  lastName: step1Form.data.lastNameInput,
  email: step1Form.data.emailInput,
});

// The spread operator merges new data with existing data.
```

**Expected result:** formData.value is {} on load. After Step 1 completes, it contains the personal info fields.

### 5. Wire the Next button with step validation and data accumulation

Add a Button component below each form (or at the bottom of the Tabbed Container). Create a JS Query named goToNextStep. In the query, validate the current step's form, accumulate its data, and advance the step counter. The Next button on Tab 1 should trigger goToNextStep. Use await because setValue() is asynchronous — ensure the step index advances only after the data is stored.

```
// JS Query: goToNextStep
// Trigger: Next button click

const step = currentStep.value;

// Validate the current step's form before advancing
let currentForm;
if (step === 0) currentForm = step1Form;
else if (step === 1) currentForm = step2Form;

if (currentForm) {
  const isValid = currentForm.validate();
  if (!isValid) {
    utils.showNotification({
      title: 'Please fix the errors',
      description: 'Fill in all required fields before continuing.',
      notificationType: 'warning',
    });
    return;
  }

  // Accumulate data from the current step
  const newData = { ...formData.value };
  if (step === 0) {
    newData.firstName = step1Form.data.firstNameInput;
    newData.lastName = step1Form.data.lastNameInput;
    newData.email = step1Form.data.emailInput;
  } else if (step === 1) {
    newData.street = step2Form.data.streetInput;
    newData.city = step2Form.data.cityInput;
    newData.country = step2Form.data.countrySelect;
  }

  await formData.setValue(newData);
}

// Advance to next tab
await currentStep.setValue(step + 1);
```

**Expected result:** Clicking Next on Step 1 with valid data saves the data to formData and shows Step 2. With invalid data, it shows error messages and stays on Step 1.

### 6. Wire the Back button to decrement the step

Add a Back button to each tab (hidden on Tab 1 using Hidden: {{ currentStep.value === 0 }}). The Back button only needs to decrement the step counter — do not clear formData, as previously entered values are preserved. When the user navigates back to a previous tab, the Form components will show empty fields (Default Values do not auto-restore). To restore values, pre-fill the form on Back navigation using step1Form.setData(formData.value) in the Back handler.

```
// JS Query: goToPreviousStep
// Trigger: Back button click

const step = currentStep.value;
if (step === 0) return; // Already on first step

// Go back
await currentStep.setValue(step - 1);

// Restore previously entered data into the form
if (step === 1) {
  // Going back to Step 1
  step1Form.setData({
    firstNameInput: formData.value.firstName,
    lastNameInput: formData.value.lastName,
    emailInput: formData.value.email,
  });
} else if (step === 2) {
  // Going back to Step 2
  step2Form.setData({
    streetInput: formData.value.street,
    cityInput: formData.value.city,
    countrySelect: formData.value.country,
  });
}
```

**Expected result:** Clicking Back shows the previous tab and restores previously entered values.

### 7. Build the Review step and final Submit query

In Tab 3 (Review), add Text components that display formData.value fields to let users confirm before submitting. Add a Submit button that triggers the final submitRegistration JS Query. This query reads from formData.value (not from the form components directly, since Step 3 has no form inputs) and calls the database insert mutation. On success, reset the entire flow by clearing formData and setting currentStep back to 0.

```
// JS Query: submitRegistration
// Trigger: Submit button on Step 3

const data = formData.value;

try {
  await insertRegistration.trigger({
    additionalScope: {
      firstName: data.firstName,
      lastName: data.lastName,
      email: data.email,
      street: data.street,
      city: data.city,
      country: data.country,
    }
  });

  utils.showNotification({
    title: 'Registration complete',
    description: `Welcome, ${data.firstName}!`,
    notificationType: 'success',
  });

  // Reset the wizard
  await formData.setValue({});
  await currentStep.setValue(0);
  step1Form.reset();
  step2Form.reset();

} catch (err) {
  utils.showNotification({
    title: 'Submission failed',
    description: err.message,
    notificationType: 'error',
  });
  throw err;
}
```

**Expected result:** Clicking Submit on Step 3 inserts the record, shows a success notification, and resets the form to Step 1.

## Complete code example

File: `JS Query: goToNextStep`

```javascript
// goToNextStep — validates current step, stores data, advances tab
// Temporary State: currentStep (Number, default: 0)
// Temporary State: formData (Object, default: {})

const step = currentStep.value;

// Map step index to form component and its data fields
const stepConfig = [
  {
    form: step1Form,
    fields: {
      firstName: step1Form.data.firstNameInput,
      lastName: step1Form.data.lastNameInput,
      email: step1Form.data.emailInput,
    }
  },
  {
    form: step2Form,
    fields: {
      street: step2Form.data.streetInput,
      city: step2Form.data.cityInput,
      country: step2Form.data.countrySelect,
      postcode: step2Form.data.postcodeInput,
    }
  },
];

const config = stepConfig[step];

if (config) {
  // Validate before advancing
  const isValid = config.form.validate();
  if (!isValid) {
    utils.showNotification({
      title: 'Please fix the errors',
      description: 'All required fields must be completed.',
      notificationType: 'warning',
    });
    return;
  }

  // Merge new step data into accumulated formData
  await formData.setValue({
    ...formData.value,
    ...config.fields,
  });
}

// Advance to the next tab
await currentStep.setValue(step + 1);
```

## Common mistakes

- **Not awaiting setValue() before advancing the step counter — the Tabbed Container jumps to the next tab before formData is updated, causing the review step to show stale data** — undefined Fix: Always await formData.setValue(newData) before await currentStep.setValue(step + 1). Both are async operations.
- **Reading form data from component references (step1Form.data.emailInput) on the final step instead of from formData.value — fails because step 1 is not the active tab and may have been reset** — undefined Fix: Accumulate data into formData.value as users complete each step. Read from formData.value in the final submit query, not from individual form component data.
- **Trying to call validate() on a form that is not in the currently visible tab — Retool may not evaluate validation for unmounted/hidden components** — undefined Fix: Always validate the form in the currently active tab (matching currentStep.value) before advancing. Do not try to validate all steps at once from the final submit.
- **Using transformers to accumulate or merge form data across steps — transformers are read-only and cannot call setValue()** — undefined Fix: Use a JS Query for all accumulation logic. Transformers can only compute derived values from existing state, not update it.

## Best practices

- Disable Tabbed Container tab header clicks so users cannot skip validation by clicking tabs directly
- Store accumulated form data in a Temporary State object using spread merging — this preserves data from earlier steps when later steps are filled
- Always await setValue() before advancing the step index to ensure data is stored before the UI transitions
- Restore form values when navigating Back using setData() — empty fields on return navigation create a confusing UX
- Use form validation (stepNForm.validate()) per step rather than waiting until final submission — this localizes error messages to the relevant step
- Add a visual progress indicator (e.g., 'Step {{ currentStep.value + 1 }} of 3') using a Text component bound to currentStep.value
- On successful final submission, reset both formData and currentStep to their defaults and call reset() on all step forms to prepare for the next entry

## Frequently asked questions

### What happens to form data if the user refreshes the page mid-wizard?

Temporary State variables are session-scoped and not persisted to the database. A page refresh resets both currentStep to 0 and formData to {}. For forms where losing progress is a serious problem, save intermediate state to a database 'draft' table after each step using a background trigger.

### Can I allow users to click back to any previous step freely without validation?

Yes — in the goToPreviousStep query, just set currentStep to the target step index without calling validate(). Validation is only needed when advancing forward. Ensure you use setData() to restore previously entered values when navigating back so users do not have to re-enter data.

### How do I show different buttons on each step (e.g., no Back on Step 1, Submit instead of Next on Step 3)?

Use the Hidden property on each button. Hide the Back button on Step 1 with {{ currentStep.value === 0 }}. Hide the Next button on the final step with {{ currentStep.value === totalSteps - 1 }}. Show the Submit button only on the final step with {{ currentStep.value !== totalSteps - 1 }}.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-implement-multi-step-forms-in-retool
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-implement-multi-step-forms-in-retool
