Lists and Keys
Render dynamic collections efficiently by giving React a stable identity for each item.
Search across all documentation pages
Render dynamic collections efficiently by giving React a stable identity for each item.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
// Basic list rendering
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
// With Fragment keys (multiple elements per item)
import { Fragment } from "react";
{entries.map(entry => (
<Fragment key={entry.id}>
<dt>{entry.term}</dt>
<dd>{entry.definition}</dd>
</Fragment>
))}
// Filtering + mapping
{users
.filter(u => u.isActive)
.map(u => <UserCard key={u.id} user={u} />)}When to reach for this: Any time you render an array of data - todo items, search results, table rows, navigation links.
"use client";
import { useState } from "react";
interface Todo {
id: string;
text: string;
done: boolean;
}
let nextId = 0;
function createId() {
return `todo-${++nextId}-${Date.now()}`;
}
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [draft, setDraft] = useState("");
function addTodo() {
const text = draft.trim();
if (!text) return;
setTodos(prev => [...prev, { id: createId(), text, done: false }]);
setDraft("");
}
function toggleTodo(id: string) {
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
);
}
function removeTodo(id: string) {
setTodos(prev => prev.filter(t => t.id !== id));
}
return (
<div className="max-w-sm space-y-3 rounded border p-4">
<form
onSubmit={e => {
e.preventDefault();
addTodo();
}}
className="flex gap-2"
>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="Add a task..."
className="flex-1 rounded border px-3 py-1"
/>
<button type="submit" className="rounded bg-blue-600 px-3 py-1 text-white">
Add
</button>
</form>
{todos.length === 0 && (
<p className="text-sm text-gray-400">No tasks yet.</p>
)}
<ul className="space-y-1">
{todos.map(todo => (
<li key={todo.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.done ? "flex-1 line-through text-gray-400" : "flex-1"}>
{todo.text}
</span>
<button
onClick={() => removeTodo(todo.id)}
className="text-xs text-red-500"
>
Remove
</button>
</li>
))}
</ul>
<p className="text-xs text-gray-500">
{todos.filter(t => !t.done).length} remaining
</p>
</div>
);
}What this demonstrates:
key based on a generated ID, not array indexmap to toggle, filter to remove, spread to addArray.prototype.map() inside JSX returns an array of elements - React renders each onekey prop tells React which item is which across re-renders so it can match old and new elements| Prop | Type | Description |
|---|---|---|
key | string or number | Stable identity for each list item - must be unique among siblings |
| Source | Good? | Why |
|---|---|---|
| Database ID | Yes | Stable and unique by definition |
| UUID / nanoid | Yes | Unique, survives reordering |
item.slug | Yes | Stable if slugs don't change |
| Array index | Sometimes | Only safe for static lists that never reorder, filter, or insert |
Math.random() | No | Creates a new key every render - forces remount every time |
Nested lists:
{categories.map(cat => (
<section key={cat.id}>
<h2>{cat.name}</h2>
<ul>
{cat.items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</section>
))}Sorted and filtered lists:
const visibleItems = useMemo(
() =>
items
.filter(item => item.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name)),
[items, query]
);
return (
<ul>
{visibleItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);Resetting component state with key:
// Changing key forces React to unmount and remount the component
<PlayerProfile key={currentPlayerId} playerId={currentPlayerId} />Multi-criteria search (filter by query + category + status):
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return products.filter(p =>
(!q || p.name.toLowerCase().includes(q)) &&
(category === "all" || p.category === category) &&
(!inStockOnly || p.stock > 0)
);
}, [products, query, category, inStockOnly]);
return (
<ul>
{visible.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);Chain the conditions inside a single filter and short-circuit with !q / "all" sentinels so every field can be independently "any value." useMemo skips the work when unrelated state changes.
Grouping with reduce, then rendering sections:
const byStatus = useMemo(() => {
return tasks.reduce<Record<string, Task[]>>((acc, task) => {
(acc[task.status] ??= []).push(task);
return acc;
}, {});
}, [tasks]);
return (
<>
{Object.entries(byStatus).map(([status, group]) => (
<section key={status}>
<h3>{status} ({group.length})</h3>
<ul>
{group.map(t => <li key={t.id}>{t.title}</li>)}
</ul>
</section>
))}
</>
);Use reduce to bucket items by a field, then Object.entries(...).map(...) to render each group. The ??= operator creates the array lazily on first insert.
Flattening nested arrays with flatMap:
// Turn [{ author, posts: [...] }, ...] into a single flat list of posts
const allPosts = threads.flatMap(thread =>
thread.posts.map(post => ({ ...post, author: thread.author }))
);
return (
<ul>
{allPosts.map(p => (
<li key={p.id}>
<strong>{p.author}:</strong> {p.body}
</li>
))}
</ul>
);flatMap is .map().flat() in one pass - use it when you need to expand each input item into zero or more outputs while injecting parent-level data onto each child.
Dispatching to different components by item type:
type FeedItem =
| { type: "post"; id: string; body: string }
| { type: "ad"; id: string; campaignId: string }
| { type: "divider"; id: string };
return (
<ul>
{feed.map(item => {
switch (item.type) {
case "post": return <PostCard key={item.id} post={item} />;
case "ad": return <AdSlot key={item.id} campaignId={item.campaignId} />;
case "divider": return <hr key={item.id} />;
}
})}
</ul>
);Discriminated unions let one .map() render a heterogeneous feed - TypeScript narrows item inside each case, so item.body and item.campaignId are only accessible on the right branch.
Pagination with slice:
const PAGE_SIZE = 20;
const pageItems = items.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<>
<ul>
{pageItems.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
<button onClick={() => setPage(p => p + 1)} disabled={(page + 1) * PAGE_SIZE >= items.length}>
Next
</button>
</>
);slice is immutable and cheap - use it for client-side pagination, "show top 5," or showing a preview of a long list.
Immutable sort with toSorted (ES2023):
// toSorted returns a new array - no need to copy first
const ranked = players.toSorted((a, b) => b.score - a.score);
return (
<ol>
{ranked.map((p, i) => (
<li key={p.id}>#{i + 1} {p.name} - {p.score}</li>
))}
</ol>
);.sort() mutates in place, which breaks useMemo invalidation and can corrupt props. Prefer toSorted() (or [...arr].sort() on older runtimes). Same pattern exists for toReversed and toSpliced.
Deduplicating before mapping:
const uniqueTags = Array.from(new Set(posts.flatMap(p => p.tags)));
return (
<div className="flex gap-2">
{uniqueTags.map(tag => (
<button key={tag} onClick={() => toggleFilter(tag)}>#{tag}</button>
))}
</div>
);new Set(...) collapses duplicates; Array.from(...) (or [...set]) turns it back into an array you can .map(). Works for primitives - for objects, dedupe by ID first.
// Typed map callback
interface Product {
id: string;
name: string;
price: number;
}
function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map(({ id, name, price }) => (
<li key={id}>
{name} - ${price.toFixed(2)}
</li>
))}
</ul>
);
}Index keys with reorderable lists - Using key={index} in a sortable or filterable list causes React to reuse the wrong DOM nodes, leading to stale input values and broken animations. Fix: Use a stable unique ID from your data.
Duplicate keys - Two siblings with the same key cause unpredictable behavior - React silently drops one. Fix: Ensure keys are unique. If your data has duplicates, combine fields: key={\${item.type}-${item.id}`}`.
Key on the wrong element - Placing key on the inner <span> instead of the outermost element returned by map does nothing. Fix: Always put key on the element immediately returned by the .map() callback.
Expensive list re-renders - A large list re-rendering on every parent render causes jank. Fix: Memoize list items with React.memo and stabilize callbacks with useCallback, or virtualize with @tanstack/react-virtual.
Forgetting empty state - An empty array renders nothing, which can look like a broken UI. Fix: Always handle items.length === 0 with a placeholder message.
| Alternative | Use When | Don't Use When |
|---|---|---|
@tanstack/react-virtual | Rendering thousands of items (virtualized list) | A short list (under 100 items) |
React.Children.map | Iterating over children prop elements | You have data arrays - use plain .map() |
CSS repeat() grid | Layout is purely visual repetition without dynamic data | Each item has unique data or state |
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Three-level nested list rendering
// File: src/components/services/content-display.tsx
{content.sections.map((section) => {
const isLoaded = loadedSections.has(section.id);
const pointCount = section._count?.sectionPoints ?? section.sectionPoints?.length ?? 0;
return (
<AccordionItem key={section.id} value={`section-${section.id}`} className="border rounded-lg">
<AccordionContent className="px-6 pb-6">
{isLoaded && section.sectionPoints && section.topics && (
<>
{section.sectionPoints.map((point) => (
<PointCard
key={point.id}
point={point}
serviceSlug={content.slug}
sectionSlug={section.sectionId}
/>
))}
{section.topics.map((topic) => (
<div key={topic.id} className="mb-6 last:mb-0">
<h4 className="text-xl font-medium">{topic.topicTitle}</h4>
<div className="ml-6 space-y-3">
{topic.topicPoints.map((point) => (
<PointCard key={point.id} point={point} serviceSlug={content.slug} sectionSlug={section.sectionId} />
))}
</div>
</div>
))}
</>
)}
</AccordionContent>
</AccordionItem>
);
})}What this demonstrates in production:
.map(): sections, topics, points, reflecting the data model hierarchykey={*.id} with stable database IDs (UUIDs), never array indices<>...</> (Fragment) wraps sibling lists without adding extra DOM nodes{isLoaded && ...} ensures points only render after lazy-loading completes for that sectionsection._count?.sectionPoints ?? section.sectionPoints?.length ?? 0 uses nullish coalescing to safely handle two different data shapes (Prisma _count vs populated arrays).map() calls can hurt readability. Consider extracting TopicList and PointList sub-components if it growsKeys tell React which item is which across re-renders. Without stable keys, React cannot correctly match old and new elements, leading to lost state, broken animations, and incorrect DOM reuse.
Only for static lists that never reorder, filter, or have items inserted/removed. For any dynamic list, use a stable unique ID from your data (database ID, UUID, or slug).
React silently drops one of them, causing unpredictable behavior. Always ensure keys are unique among siblings. Combine fields if needed: key={`${item.type}-${item.id}`}.
Chain .filter() and .sort() before .map(). Wrap in useMemo if the list is large:
const visible = useMemo(
() => items.filter(i => i.active).sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
return <ul>{visible.map(i => <li key={i.id}>{i.name}</li>)}</ul>;setItems(prev => [...prev, newItem])setItems(prev => prev.filter(i => i.id !== id))setItems(prev => prev.map(i => i.id === id ? { ...i, done: true } : i))Always on the outermost element returned by the .map() callback. Placing it on an inner child element has no effect.
It generates a new value every render, so React treats every item as new - unmounting and remounting all components each time. This destroys state and kills performance.
Check items.length === 0 and render a placeholder message. An empty array renders nothing, which can look like a broken UI.
Import Fragment from React and use the named syntax:
import { Fragment } from "react";
{items.map(item => (
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</Fragment>
))}The short syntax <> does not support keys.
Change the key to a new value. React unmounts the old component and mounts a fresh one with initial state:
<PlayerProfile key={currentPlayerId} playerId={currentPlayerId} />When rendering more than a few hundred items causes visible jank. Use @tanstack/react-virtual to only render items currently in the viewport, keeping DOM size small.
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥