A tiny, type-safe React component focused on improving DX when rendering lists. No, this is not another dependency in your package.json.
It removes redundancy, centralizes common list logic, and enforces (or safely infers) stable keys, making list rendering simpler and less error-prone, in a way similar to how other React environments approach list rendering.
This component does not style anything or hide React’s rules.
It only provides a small set of opinionated defaults and fails loudly when correctness cannot be guaranteed, with a clean mental model to follow.
This component is designed to be copied into your codebase, not installed as a runtime dependency. The CLI will prompt for a destination path and copy the component directly into your project.
| npm | pnpm | yarn | bun |
|---|---|---|---|
npx @luk4x/list |
pnpm dlx @luk4x/list |
yarn @luk4x/list |
bunx --bun @luk4x/list |
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>
);
}With List, the same output is simplified to:
<List items={items} renderEmpty={() => <p>Empty</p>}>
{item => <li>{item.title}</li>}
</List>"Wait, but where's
key?" In this example, the key can be safely inferred. SeekeyExtractorfor the exact rules.
| Prop | Required | Purpose | Default |
|---|---|---|---|
items |
No | array of items to be rendered |
— |
children |
Yes | function to render each list item |
— |
keyExtractor |
Depends on list type | function to extract a stable key |
— |
renderEmpty |
No | function to render list empty state |
() => null |
as |
No | string to choose the list element |
'ul' |
All native list element props (
ref,className,data-*,aria-*...) are supported as well.
Type: ReadonlyArray<Item> | null | undefined
The list of items to be rendered.
When items is null, undefined, or [], renderEmpty is rendered instead.
Type: (item: Item, index: number, array: ReadonlyArray<Item>) => ReactNode
Render function for each list item.
Prefer returning a <li> element to preserve correct semantics, or at least an element that has the role="listitem" attribute.
Type: (item: Item, index: number) => React.Key
Function used to extract a stable key for each list item.
This prop is conditionally required.
Optional (key can be inferred) when:
itemsare primitive values that are valid asReact.Key(typicallystringornumber)itemsare objects with a stableid: React.Keyproperty
Required (key cannot be inferred) when:
- primitive values are not valid keys
idproperty is optional,any, orunknown
If keyExtractor is omitted when required, the component throws at runtime. There's no default workaround, such using index as fallback.
You can always provide keyExtractor to explicitly override the inferred behavior if you wish.
The keys must be unique. If your primitive list or
idproperty can contain duplicates (which is usually a data-model smell), handle it explicitly viakeyExtractor.
Type: () => ReactNode
Default: () => null
Render function used when the list has no items.
Rendered when items is null, undefined, or [].
The list root (ul / ol) is not rendered when empty. This avoids rendering meaningless list containers and forces layout decisions to be explicit.
Type: 'ul' | 'ol'
Default: 'ul'
Choose the semantic list element.
When rendering lists in React, one rule simplifies everything:
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.
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 is not philosophy, it’s a clean way to align:
- your data
- your UI
- and React’s rules
Does this mental model hurt readability?
Only if it’s applied in the wrong place. This isn’t a rule for all data, it’s a UI-boundary rule, 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 ceremonyBoth are correct. Choose the one that reads clearer in your codebase.
In the
profileTabsexample,tabwas already acting as identity. Renaming it toiddoesn’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
profileTabsidentity, without any additional ceremony.
In short, at runtime, List 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 inferred and has not been explicitly extracted
Structurally, the output is equivalent to:
<ul>
{items.map((item, index) => (
<React.Fragment key={resolvedKey}>
{children(item, index, items)}
</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
This section highlights common, practical list rendering scenarios using List.
<List items={['Yagate Kimi ni Naru', 'Gosick', 'Ookami to Koushinryou']}>
{animeTitle => <li>{animeTitle}</li>}
</List><List
items={[
{ id: 1, title: 'Yagate Kimi ni Naru' },
{ id: 2, title: 'Gosick' },
{ id: 3, title: 'Ookami to Koushinryou' },
]}
>
{anime => <li>{anime.title}</li>}
</List><List
items={[
{ title: 'Yagate Kimi ni Naru' },
{ title: 'Gosick' },
{ title: 'Ookami to Koushinryou' },
]}
keyExtractor={anime => anime.title}
>
{anime => <li>{anime.title}</li>}
</List>const generateSkeletonKeys = (length: number, context: string) => {
return Array.from({ length }, (_, idx) => `${context}-${idx}`);
};
<List items={generateSkeletonKeys(5, 'home-card-skeleton')}>
{() => (
<li>
<Skeleton className="h-4 w-12 rounded-sm" />
</li>
)}
</List>;type TAnime = { id: number; title: string; rating: number };
const animes: Array<TAnime> | undefined | null = [];
<List
items={animes?.filter(anime => anime.rating >= 4)}
renderEmpty={() => <p>No top-rated animes.</p>}
>
{anime => <li>{anime.title}</li>}
</List>;<List
as="ol"
items={['Yagate Kimi ni Naru', 'Gosick', 'Ookami to Koushinryou']}
>
{animeTitle => <li>{animeTitle}</li>}
</List><List
items={[
{ id: 1, title: 'Yagate Kimi ni Naru' },
{ id: 2, title: 'Gosick' },
{ id: 3, title: 'Ookami to Koushinryou' },
]}
keyExtractor={(anime, index) => `${anime.id}-${anime.title}-${index}`}
>
{anime => <li>{anime.title}</li>}
</List>