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>
)}
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>
“Where’s the
key?” In this example, it can be safely inferred. SeekeyExtractordocs 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 },
];
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. 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>
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
keyto that fragment (viakeyExtractoror inference) - Throws if a stable
keycannot be determined
Conceptually
<ul>
{items.map((item, index, array) => (
<React.Fragment key={resolvedKey}>
{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
- 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
keyis missing - 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, 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)