DEV Community

Cover image for Making List Correctness the Default in React
Lucas Maciel Francisco
Lucas Maciel Francisco

Posted on • Originally published at blog.luk4x.dev

Making List Correctness the Default in React

Stop rewriting the same list boilerplate over and over. At scale, repetition isn’t just annoying, it’s how correctness rules decay and key bugs, silent fallbacks, and broken semantics quietly slip into React codebases.

This post is about a small React abstraction that makes list correctness the default behavior, without hiding React’s rules, adding dependencies, or reinventing anything.


The problem isn’t .map, it’s everything around it

In most React codebases, list rendering ends up looking like some variation of this boilerplate:

{items?.length === 0 ? (
  <p>Empty</p>
) : (
  <ul>
    {items.map(item => (
      <li key={item.id}>{item.title}</li>
    ))}
  </ul>
)}
Enter fullscreen mode Exit fullscreen mode

In isolation, this isn’t a problem at all. At scale, it becomes fragile.

Over time, list rendering logic spreads across the codebase:

  • Key logic handled inconsistently
  • Redundancy of the same common list logics
  • Index fallbacks sneaking in during refactors
  • Empty states handled inconsistently
  • Even basic list semantics, slowly degrading into <div>
  • Correctness relying on remembering the rules

Most teams try to solve this with linting and conventions:

  • “Always add a key
  • “Avoid using index as fallback”
  • “Prefer semantic lists”
  • “Handle empty states properly”

It works, until the codebase grows.

As complexity increases:

  • New contributors miss edge cases
  • Refactors invalidate assumptions
  • Reviewers focus on business logic, not list correctness
  • Subtle reconciliation bugs slip through

The problem isn’t React or that developers don’t know the rules, it’s a DX + enforcement problem.


A different approach: guardrails at the UI boundary

Instead of asking to “remember” correctness, I built a tiny abstraction that makes the correct thing the easy thing, and fails loudly when correctness can’t be guaranteed.

npx @luk4x/list

It’s not a runtime dependency, it’s a CLI. It will copy the component into your codebase. Full implementation and docs here: github.com/luk4x/list ↗

At a high level, it does two things:

  • centralizes common list rendering logic
  • enforces or safely infers stable keys

The same example above becomes:

<List items={items} renderEmpty={() => <p>Empty</p>}>
  {item => <li>{item.title}</li>}
</List>
Enter fullscreen mode Exit fullscreen mode

“Where’s the key?” In this example, it can be safely inferred. See keyExtractor docs for the exact rules.


A clean mental model: explicit identity in UI lists

When rendering lists in React, one rule must be followed:

Every list item must have a stable, unique property that represents its identity.

In practice, this works best when UI data is modeled with an explicit identity.

Example

Instead of relying on some implicit unique identity property:

const profileTabs = [
  { tab: 'settings-tab', label: 'Settings', Icon: SettingsIcon },
  { tab: 'security-tab', label: 'Security', Icon: ShieldCheckIcon },
  { tab: 'billing-tab', label: 'Billing', Icon: CreditCardIcon },
];
Enter fullscreen mode Exit fullscreen mode

Make the identity explicit:

const profileTabs = [
  { id: 'settings-tab', label: 'Settings', Icon: SettingsIcon },
  { id: 'security-tab', label: 'Security', Icon: ShieldCheckIcon },
  { id: 'billing-tab', label: 'Billing', Icon: CreditCardIcon },
];
Enter fullscreen mode Exit fullscreen mode

Here, tab was already the identity. Making it explicit as id simply acknowledges that fact, and removes the need for key ceremony when using List.

<List items={profileTabs}>
  {({ id, label, Icon }) => (
    <li>
      <button onClick={() => onSelectTab(id)}>
        <Icon size={20} /> {label}
      </button>
    </li>
  )}
</List>
Enter fullscreen mode Exit fullscreen mode

This mental model isn’t philosophy, it’s a clean way to align:

  • your data
  • your UI
  • and React’s rules

Scope matters

This isn’t for all data, it’s a UI-boundary mental model, meant for data that is mapped into rendered lists.

At that boundary, you have two valid options:

  • keep a domain-specific field and use keyExtractor
  • normalize identity to an id and remove key ceremony

Both are correct. Choose the one that reads clearer in your codebase.

In the profileTabs example, renaming tab to id doesn’t erase meaning, the context still makes it obvious what the value represents.

The difference is that now both you and React can infer the profileTabs identity, without any additional ceremony.


How the abstraction actually works

At runtime, the component does exactly this:

  • Renders a <ul> by default
  • Iterates over items
  • Wraps each rendered child in a React.Fragment
  • Assigns a validated key to that fragment (via keyExtractor or inference)
  • Throws if a stable key cannot be determined

Conceptually

<ul>
  {items.map((item, index, array) => (
    <React.Fragment key={resolvedKey}>
      {children(item, index, array)}
    </React.Fragment>
  ))}
</ul>
Enter fullscreen mode Exit fullscreen mode
  • No attempt is made to validate child structure beyond key handling
  • No styling or layout decisions are imposed
  • No effort is made to “fix” unstable or poorly shaped data
  • Does not hide React behavior

It’s intentionally small, with a single goal:

Make correct list rendering the default.


Why enforcement beats advice

Lint rules help, they catch obvious mistakes and prevent the worst footguns.

But linting is, by nature, advisory:

  • It can warn that a key is missing
  • It can’t guarantee that the key is stable
  • It can’t enforce identity modeling

Most importantly, it can’t make the correct pattern the easiest one to use.

Lint rules operate outside your runtime model, they comment on your code, they don’t shape how it behaves.

That’s why List abstraction fails loudly. If a stable key can’t be inferred and no keyExtractor is provided, it throws at runtime. There’s no silent fallback, correctness is either guaranteed or rejected.

Correctness is moved into the rendering boundary itself, where mistakes become hard to make, instead of being merely discouraged by guidelines.

Lint rules still matter, they just work best alongside structural guardrails, not in place of them.

Top comments (0)