Problem: Managing Stateful DOM Elements in Dynamic Lists
Keeping stateful DOM elements in sync with a mutable list of data is one of those challenges that seems simple at first but quickly spirals into code spaghetti. This issue is present in almost every major JavaScript framework—whether you're working with React, Vue, or Svelte.
Let's Set the Scene
Picture this:
You have a list of labels and want to render a form input for each one. Seems easy enough, right? But now, what if these labels don't have unique identifiers.
At first, you may think:
"I'll just use the label itself as a key."
But when labels aren’t guaranteed to be unique, this approach quickly falls apart.
The Wrong Approach: Using Array Index as a Key
You might try using the array index as a key:
{rows.map((row, i) => (
<Fragment key={i}>
<label>{row.label}</label>
<input placeholder={row.placeholder} />
</Fragment>
))}
While this silences immediate errors, linting tools like biomejs
(and others) will fill your terminal with errors for doing this—for good reason! Using indices as keys can lead to poor performance and cause state misalignment when your list changes (e.g., when adding or removing items).
Tip
Learn why JavaScript frameworks need keys to render arrays.
Also learn why SolidJS is an exception to that
What About Fully Managed State?
Another option might cross your mind:
"Why not manage the entire form state manually?"
Sure, you can try. But this means:
- Shuffling state across multiple components on every change.
- Writing complex handlers to manage every input interaction.
- Running a small mountain of JavaScript just to maintain synchronization.
What If There's a Simpler Way?
What if I told you there's a simpler and faster way to avoid this dilemma?
Let me show you how to stop fighting your framework's rendering logic.
Solution: Simpler Than You Thought
Just change your state from this
const [rows, setRows] = useState<Row[]>(state);
to this
const [rows, setRows] = useState<[Row, number][]>(
state.map((row, key) => [row, key]),
);
And that's it!
Now instead of unstable indicies you have stable keys that are nicely packed alongside with your data.
{rows.map(([row, key]) => (
<Fragment key={key}>
<label>{row.label}</label>
<input placeholder={row.placeholder} />
</Fragment>
))}
Examples: Usage in Practice
Here's how your remove
and append
functions may look like
const remove = (key: number) =>
setRows((state) => state.filter((row) => row[1] !== key));
const append = () =>
setRows((state) => [...state, [fourth, (state.at(-1)?.[1] ?? -1) + 1]]);
{rows.map(([row, key]) => (
<Fragment key={key}>
<label>{row.label}</label>
<input placeholder={row.placeholder} />
<button type="button" onClick={() => remove(key)}>
Delete
</button>
</Fragment>
))}
<button type="button" onClick={append}>Append</button>
In Svelte
{#each rows as [row, key] (key)}
<label>{row.label}</label>
<input placeholder={row.placeholder} />
{/each}
And in Vue
<template v-for="[row, key] in rows" :key="key">
<label>{{ row.label }}</label>
<input :placeholder="row.placeholder" />
</template>
Conclusion
And that's it for this small JS trick you may use in your front-end projects.
Feel free to share thoughts and questions in the comments
Top comments (0)