Making List Correctness the Default in React
Source: Dev.to
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.
Originally published on Medium ↗
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 ? (
Empty
) : (
{items.map(item => (
- {item.title}
))}
)}
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 logic
- Index fallbacks sneaking in during refactors
- Empty states handled inconsistently
Even basic list semantics slowly degrade into:
- 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 developers 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 that copies the component into your codebase. Full implementation and docs are 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} fallback={<Empty />}>
{item => <li>- {item.title}</li>}
</List>
“Where’s the key?” In this example it can be safely inferred. See the 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 property:
const profileTabs = [
{ tab: 'settings-tab', label: 'Settings', Icon: SettingsIcon },
{ tab: 'security-tab', label: 'Security', Icon: ShieldCheckIcon },
{ tab: 'billing-tab', label: 'Billing', Icon: CreditCardIcon },
];
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 },
];
Here, tab was already the identity. Renaming it to id simply acknowledges that fact and removes the need for key ceremony when using List.
<List items={profileTabs} keyExtractor={item => item.id}>
{({ id, label, Icon }) => (
<button onClick={() => onSelectTab(id)}>
{label}
</button>
)}
</List>
This mental model isn’t philosophy; it’s a clean way to align:
- your data
- your UI
- 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
idand 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 both you and React can infer the profileTabs identity without additional ceremony.
How the abstraction actually works
At runtime the component does exactly this:
- Renders a
<ul>(or the element you specify) by default - Iterates over
items - Wraps each rendered child in a
React.Fragment - Assigns a validated
keyto that fragment (viakeyExtractoror inference) - Throws if a stable
keycannot be determined
Conceptually
<ul>
{items.map((item, index, array) => (
<React.Fragment key={keyExtractor ? keyExtractor(item) : inferKey(item)}>
{children(item, index, array)}
</React.Fragment>
))}
</ul>
- 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
- It 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 foot‑guns.
But linting is, by nature, advisory:
- It can warn that a
keyis missing or duplicated - It can’t guarantee that the
keyis 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, but they don’t shape how it behaves.
That’s why the 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 work best alongside structural guardrails, not in place of them.