Tutorials
How to Build a Dynamically Growing Form in Retool

If you're trying to build a dynamically growing form in Retool — one where users can keep adding rows, like an "if this then that" rule builder — you've probably already hit the wall. The built-in Form component doesn't support dynamic row addition, and the jsonSchemaForm component is too rigid for real-world use cases. The good news: ListView combined with a tempState variable is the cleanest, most flexible solution available in Retool today.
Why the Default Form Component Falls Short
Retool's standard Form component is great for fixed-field forms, but it doesn't allow you to programmatically inject or remove components at runtime. The jsonSchemaForm gets closer — you can define fields dynamically via a JSON schema — but it comes with serious limitations. You can't populate a Select field with options fetched from a database or API, and laying out multiple fields in a single row is awkward at best. For anything beyond a simple flat form, it breaks down fast.
Why ListView Is the Right Tool for Dynamic Forms
The ListView component is designed to render a repeating set of components for each item in an array. That makes it a natural fit for dynamic forms: each "row" of your form is one item in the list, and the list grows or shrinks as the underlying array changes. You get full control over the layout of each row — dropdowns pulling from queries, text inputs, buttons, whatever you need — and the user experience is clean and intuitive.
Step-by-Step: Building a Dynamic Form with ListView and tempState
- Step 1 — Create a temporary state variable. In the left panel, add a new
tempStatevariable (e.g.formRows). Set its default value to an array with one starter object, like[{ condition: "", action: "" }]. This array is the single source of truth for your form rows. - Step 2 — Add a ListView component. Drop a
ListViewonto your canvas. Set its data property to{{ formRows.value }}. The list will now render one row for every object in your state array. - Step 3 — Build your row template. Inside the
ListView, add the components that make up one row — aSelect, aTextInput, a deleteButton, or whatever your use case requires. Use{{ currentSourceData }}to bind each component's default value to the corresponding field in the current row's object (e.g.{{ currentSourceData.condition }}). - Step 4 — Wire up the "Add Row" button. Place a
Buttonoutside theListViewlabeled "Add Row". In its event handler, run a JavaScript snippet that appends a new blank object to your state:formRows.setValue([...formRows.value, { condition: "", action: "" }]). - Step 5 — Wire up per-row deletion. Inside the
ListView, the deleteButtonfor each row should run:formRows.setValue(formRows.value.filter((_, i) => i !== i)). UselistItem.index(available inside aListViewcontext) to identify the row:formRows.setValue(formRows.value.filter((_, i) => i !== listItem.index)). This lets users remove any specific row — not just the last one. - Step 6 — Sync edits back to state. On each input component inside the
ListView, use the onChange event to update the correct row in your state. For example:const updated = [...formRows.value]; updated[listItem.index].condition = self.value; formRows.setValue(updated); - Step 7 — Submit the form. When the user clicks Save,
formRows.valuecontains a clean array of objects. Pass it directly into a query — a SQLINSERT, a REST API call, or anything else — and you're done.
What About Removing Specific Rows?
This is the question that trips most people up. The common assumption is that ListView only lets you remove the last item. That's not true — it just requires using the row's index. Because ListView exposes listItem.index for every row, you can use a standard array filter to remove exactly the row the user clicked. No hacks, no workarounds.
When to Use jsonSchemaForm Instead
The jsonSchemaForm component is still useful for simpler cases where all field options are static and you're comfortable with the JSON Schema spec. If your form fields don't depend on live data from a resource, and you don't need multi-column row layouts, it's a lower-effort option. But the moment you need a Select populated from a query, or you need full layout control, ListView + tempState is the way to go.
Key Takeaways
- Retool's
Formcomponent does not support dynamic row addition — don't fight it. ListViewbound to atempStatearray is the correct pattern for dynamically growing forms.- Use
listItem.indexinsideListViewto enable deletion of any specific row. - On save,
tempState.valuegives you a ready-to-use array of objects you can pipe straight into a query. - This pattern works for rule builders, line-item forms, multi-condition filters, and any other repeating-row UI.
It takes a bit of wiring the first time, but once the pattern clicks it's remarkably flexible. You end up with a fully dynamic, user-controlled form that produces a clean data structure — no extra database round-trips until the user actually hits Save.
Ready to build?
We scope, design, and ship your Retool app — fast.