DEV Community

Cover image for Syncing DOM State and Data without IDs
Sby
Sby

Posted on

Syncing DOM State and Data without IDs

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>
))}
Enter fullscreen mode Exit fullscreen mode

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).

Example of desynced state

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);
Enter fullscreen mode Exit fullscreen mode

to this

const [rows, setRows] = useState<[Row, number][]>(
  state.map((row, key) => [row, key]),
);
Enter fullscreen mode Exit fullscreen mode

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>
))}
Enter fullscreen mode Exit fullscreen mode

Example of correctly synced state

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>
Enter fullscreen mode Exit fullscreen mode

In Svelte

{#each rows as [row, key] (key)}
  <label>{row.label}</label>
  <input placeholder={row.placeholder} />
{/each}
Enter fullscreen mode Exit fullscreen mode

And in Vue

<template v-for="[row, key] in rows" :key="key">
  <label>{{ row.label }}</label>
  <input :placeholder="row.placeholder" />
</template>
Enter fullscreen mode Exit fullscreen mode

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)