Rendering Lists, Keys and Filtering in React

React & Next.js: The Complete Developer HandbookLesson 7 of 54

Part 1: React Foundations · 7 of 16

Goal: render the catalogue from data with map, give every item a key that keeps its identity across renders, and build category filters, search and sorting as values derived from state rather than as extra copies of the data.

The tool library’s catalogue is a list, and so are the basket, a member’s loans and the repair queue. Lists are where a few React rules that seem fussy (keys, not mutating arrays, deriving instead of storing) turn into visible bugs: a row showing another tool’s loan length, a sort that changes the data for the whole app, a filter that loses items. By the end of this lesson the catalogue can be filtered, searched and sorted, with none of those bugs.

From an array to JSX with map

JSX can render an array of elements: put the array inside braces and React renders each element in order. React has no special loop syntax; you use the array’s own map method to turn each data item into an element:

const TOOLS = [
  { id: 't1', name: 'Cordless drill', status: 'available' },
  { id: 't2', name: 'Extension ladder', status: 'on-loan' },
  { id: 't4', name: 'Hedge trimmer', status: 'in-repair' },
  { id: 't5', name: 'Pressure washer', status: 'reserved' },
];

const STATUS_LABELS = {
  available: 'Available',
  reserved: 'Reserved',
  'on-loan': 'On loan',
  'in-repair': 'In repair',
};

function ToolList() {
  return (
    <ul>
      {TOOLS.map((tool) => (
        <li key={tool.id}>
          {tool.name}: {STATUS_LABELS[tool.status]}
        </li>
      ))}
    </ul>
  );
}

It renders four rows, from “Cordless drill: Available” to “Pressure washer: Reserved”. Two details are worth noticing. The status labels come from a lookup object rather than a chain of ternaries, so adding a status means adding one line. And the arrow function uses parentheses, (tool) => ( … ), which return the JSX implicitly. If you switch to braces because you need a variable first, you must write return yourself; forgetting it is a classic “why is my list empty?” moment, because map then produces an array of undefined, which React renders as nothing.

{TOOLS.map((tool) => {
  const label = STATUS_LABELS[tool.status];
  return <li key={tool.id}>{tool.name}: {label}</li>; // return is required with braces
})}

Keep map callbacks short. When a row grows beyond a few lines, move it into a component such as ToolCard (Lesson 3) and map to that.

Why list items need keys

When a list re-renders, React compares the new elements with the previous ones to work out the smallest DOM change. Without help, it can only match them by position: first with first, second with second. That is wrong as soon as items are added, removed or reordered. A key gives each item an identity: “this is the element for tool t3”, wherever it appears. With keys, React can move, insert and remove the right DOM nodes, and, just as important, keep each component’s state attached to the right item.

Forget the key and React tells you. This example leaves it out on purpose; run it and look at the console:

const TOOLS = [
  { id: 't6', name: 'Tile cutter' },
  { id: 't7', name: 'Stud finder' },
];

function HandTools() {
  return (
    <ul>
      {TOOLS.map((tool) => (
        <li>{tool.name}</li>
      ))}
    </ul>
  );
}

The list renders, and the console shows a development warning that each child in a list should have a unique “key” prop. The warning only appears in development builds, which is exactly why you should keep the console open while you work: in production the list just misbehaves quietly. Add key={tool.id} to the <li> and run it again; the warning disappears.

Choosing a good key

A good key is stable (the same item gets the same key on every render) and unique among its siblings (two lists on a page may reuse the same keys). Database ids and slugs are ideal; the tool library has both, t1 and cordless-drill. Two tempting alternatives break the rules:

  • The array index is stable only while the list never changes order or membership. Remove or sort an item and every index after it now points at a different tool.
  • A random value such as key={Math.random()} changes on every render. React treats every item as new each time, throws away its DOM and state, and recreates it: typing in an input inside such a row loses focus after each keystroke.

The index bug is worth seeing once. Each loan row keeps its own length in state. Click +1 day on the drill twice, so it shows 3 days, then click the drill’s Remove:

import { useState } from 'react';

function LoanRow({ name, onRemove }) {
  const [days, setDays] = useState(1);
  return (
    <li>
      {name}: {days} {days === 1 ? 'day' : 'days'}{' '}
      <button type="button" onClick={() => setDays((d) => d + 1)}>+1 day</button>{' '}
      <button type="button" onClick={onRemove}>Remove</button>
    </li>
  );
}

function LoanList() {
  const [tools, setTools] = useState([
    { id: 't1', name: 'Cordless drill' },
    { id: 't3', name: 'Orbital sander' },
    { id: 't6', name: 'Tile cutter' },
  ]);

  function handleRemove(toolId) {
    setTools((prev) => prev.filter((tool) => tool.id !== toolId));
  }

  return (
    <ul>
      {tools.map((tool, index) => (
        <LoanRow key={index} name={tool.name}
          onRemove={() => handleRemove(tool.id)} />
      ))}
    </ul>
  );
}

The drill row disappears, but the orbital sander now claims 3 days, a value the member set for the drill. React saw keys 0, 1, 2 become 0, 1, so it kept the components for keys 0 and 1 (with their state) and removed the one with key 2. Only the names moved. Change key={index} to key={tool.id} and try again: the sander keeps its own 1 day, because React now removes the component whose key was t1.

Three more rules about keys:

  • The key goes on the outermost element the map returns. When you map to <ToolCard tool={tool} />, the key goes on ToolCard, not on the <li> inside it.
  • A key is not a prop. React keeps it for itself; inside ToolCard, props.key is not available. Pass the id separately if the component needs it.
  • Fragments can have keys. If each item renders two siblings, such as a <dt> and a <dd>, wrap them in <Fragment key={tool.id}> (imported from react); the short <> syntax can’t take a key.

The index is acceptable as a key only for a list that is never reordered, filtered or edited and whose rows hold no state, such as the fixed steps of a “How borrowing works” guide. If the data has no id at all, add one when the data is created (Lesson 5), not while rendering.

Filtering and search as derived values

Members want to see only garden tools, or only tools whose name contains “sander”. The tempting design keeps a visibleTools array in state and filters it in each handler. It goes wrong quickly: filter by “Garden” and then by “Power tools”, and the second filter runs on the garden tools, finds nothing, and the other tools are gone from state for good. Every handler also has to remember to refilter whenever the catalogue changes.

Apply the rule from Lesson 5 instead: store only what the member chose, and calculate the visible list during render. Choose “Garden”, then type “lawn” in the search:

import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', category: 'Power tools' },
  { id: 't3', name: 'Orbital sander', category: 'Power tools' },
  { id: 't4', name: 'Hedge trimmer', category: 'Garden' },
  { id: 't6', name: 'Tile cutter', category: 'Hand tools' },
  { id: 't8', name: 'Lawn aerator', category: 'Garden' },
];
const CATEGORIES = [...new Set(TOOLS.map((tool) => tool.category))];

function Catalogue() {
  const [category, setCategory] = useState('All');
  const [query, setQuery] = useState('');

  const search = query.trim().toLowerCase();
  const visibleTools = TOOLS.filter((tool) =>
    (category === 'All' || tool.category === category) &&
    tool.name.toLowerCase().includes(search)
  );

  return (
    <>
      <label htmlFor="category">Category</label>{' '}
      <select id="category" value={category}
        onChange={(e) => setCategory(e.target.value)}>
        <option value="All">All</option>
        {CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
      </select>{' '}
      <label htmlFor="search">Search</label>{' '}
      <input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {visibleTools.map((tool) => <li key={tool.id}>{tool.name}</li>)}
      </ul>
    </>
  );
}

“Garden” shows the hedge trimmer and the lawn aerator; typing “lawn” leaves only the aerator. Switch back to “All” and every matching tool returns, because nothing was ever removed: TOOLS is untouched, and visibleTools is recalculated from it and the two choices on every render. Notice also:

  • The search is normalised (trimmed and lower-cased) once, and both sides of the comparison are lower case, so “Lawn”, “lawn ” and “LAWN” all match.
  • The category options are derived from the data too, so a new category appears in the menu without anyone editing the JSX. new Set removes the duplicates.
  • Both filters live in one filter call, joined with &&. An empty search matches every name, because every string includes ''.

This list filters on every keystroke, which is right for a catalogue this size. Lesson 12 adds a debounced search, and Lesson 15 keeps typing smooth when filtering a much larger list. In a Next.js app, filters often live in the URL instead of state, so a filtered catalogue can be bookmarked and shared; Lesson 43 covers that choice.

Sorting without mutating

Sorting has one trap: Array.prototype.sort sorts the array in place. Called on TOOLS, a prop or a state array during render, it changes data that other components and earlier renders share, which breaks the purity rule from Lesson 1. Sort a copy instead: toSorted returns a new sorted array, and [...list].sort(…) does the same in older browsers.

Sorting by name is a matter of localeCompare, which compares text the way people expect. Sorting by status needs an order you decide; a lookup object turns each status into a number. Pick “Status” from the menu:

import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', status: 'available' },
  { id: 't2', name: 'Extension ladder', status: 'on-loan' },
  { id: 't4', name: 'Hedge trimmer', status: 'in-repair' },
  { id: 't5', name: 'Pressure washer', status: 'reserved' },
  { id: 't7', name: 'Stud finder', status: 'available' },
];
const STATUS_ORDER = { available: 0, reserved: 1, 'on-loan': 2, 'in-repair': 3 };

function compareTools(sortBy) {
  return (a, b) =>
    sortBy === 'status'
      ? STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.name.localeCompare(b.name)
      : a.name.localeCompare(b.name);
}

function SortedTools() {
  const [sortBy, setSortBy] = useState('name');
  const sortedTools = TOOLS.toSorted(compareTools(sortBy));

  return (
    <>
      <label htmlFor="sort">Sort by</label>{' '}
      <select id="sort" value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="name">Name</option>
        <option value="status">Status</option>
      </select>
      <ol>
        {sortedTools.map((tool) => <li key={tool.id}>{tool.name} ({tool.status})</li>)}
      </ol>
    </>
  );
}

By name the list runs from Cordless drill to Stud finder. By status, the two available tools come first (alphabetically, thanks to the ||, which falls back to the name when two statuses are equal), then the pressure washer, the ladder and the hedge trimmer. Because the keys are tool ids, React moves the existing <li> elements rather than rewriting each one.

When you filter and sort, filter first: there is less to sort. The whole pipeline is three derived lines, and none of them is state.

const search = query.trim().toLowerCase();
const matches = TOOLS.filter((tool) => matchesFilters(tool, category, search));
const visibleTools = matches.toSorted(compareTools(sortBy));

Counts and empty states

A filtered list can be empty, and an empty <ul> looks like a broken page. Check the derived array’s length and show a helpful message instead, ideally with a way out, such as a button that clears the filters. A short summary such as “Showing 2 of 8 tools” helps too, and it is derived for free from the two arrays you already have.

Sighted members see the list change as they type; screen reader users don’t, unless you tell them. Putting the summary in an element with role="status" makes it a polite live region: screen readers announce its new text when it changes, without moving focus. The element must already be on the page before its text changes, so render it all the time and change only its contents.

return (
  <>
    <p role="status">Showing {visibleTools.length} of {TOOLS.length} tools</p>
    {visibleTools.length === 0 ? (
      <p>
        No tools match your search.{' '}
        <button type="button" onClick={handleClear}>Clear filters</button>
      </p>
    ) : (
      <ul>{visibleTools.map((tool) => <ToolCard key={tool.id} tool={tool} />)}</ul>
    )}
  </>
);

Common mistakes

  • Index or random keys on a changing list. In review, any key={index} on a list that can be filtered, sorted or edited deserves a question, and key={Math.random()} is always wrong. Test by giving a row some state (an open panel, a typed value) and then removing the row above it.
  • Sorting in place. tools.sort(…) on props, state or shared data. Search for .sort( and check it is called on a copy, or use toSorted.
  • Storing the filtered list in state. A filteredTools state updated in handlers drifts from the data and can lose items. Store the filter choices; derive the list.
  • The key in the wrong place. A key on the <li> inside ToolCard does nothing for the list; the warning stays until the key is on <ToolCard> in the map.
  • No empty state. Search for something that doesn’t exist; if you see a blank space, the component needs a message.

Build it: the searchable catalogue

Bring it together for the full catalogue of eight tools. The starter renders the controls and the whole list but nothing is connected:

  • Filter by category (“All” plus every category in the data) and by search text in the tool name, ignoring case and surrounding spaces.
  • Sort by name or by status (available first, then reserved, on loan, in repair; ties by name).
  • Show “Showing N of 8 tools” in a role="status" element, and when nothing matches, a message with a Clear filters button that resets the category and the search.
  • The only state is the three choices. Use tool ids as keys.
import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', category: 'Power tools', status: 'available' },
  { id: 't2', name: 'Extension ladder', category: 'Ladders & access', status: 'on-loan' },
  { id: 't3', name: 'Orbital sander', category: 'Power tools', status: 'available' },
  { id: 't4', name: 'Hedge trimmer', category: 'Garden', status: 'in-repair' },
  { id: 't5', name: 'Pressure washer', category: 'Cleaning', status: 'reserved' },
  { id: 't6', name: 'Tile cutter', category: 'Hand tools', status: 'available' },
  { id: 't7', name: 'Stud finder', category: 'Hand tools', status: 'available' },
  { id: 't8', name: 'Lawn aerator', category: 'Garden', status: 'available' },
];

function Catalogue() {
  return (
    <>
      <label htmlFor="cat">Category</label> <select id="cat"><option>All</option></select>{' '}
      <label htmlFor="q">Search</label> <input id="q" />{' '}
      <label htmlFor="sort-by">Sort by</label>{' '}
      <select id="sort-by"><option>Name</option><option>Status</option></select>
      <ul>
        {TOOLS.map((tool) => <li key={tool.id}>{tool.name}: {tool.status}</li>)}
      </ul>
    </>
  );
}

Hint: write the derived values first, as plain constants under the three useState calls: the categories, the search text, the filtered list, the sorted list. The JSX then only reads them. “Clear filters” is a handler that calls two setters.

Show a sample solution
import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', category: 'Power tools', status: 'available' },
  { id: 't2', name: 'Extension ladder', category: 'Ladders & access', status: 'on-loan' },
  { id: 't3', name: 'Orbital sander', category: 'Power tools', status: 'available' },
  { id: 't4', name: 'Hedge trimmer', category: 'Garden', status: 'in-repair' },
  { id: 't5', name: 'Pressure washer', category: 'Cleaning', status: 'reserved' },
  { id: 't6', name: 'Tile cutter', category: 'Hand tools', status: 'available' },
  { id: 't7', name: 'Stud finder', category: 'Hand tools', status: 'available' },
  { id: 't8', name: 'Lawn aerator', category: 'Garden', status: 'available' },
];
const CATEGORIES = [...new Set(TOOLS.map((tool) => tool.category))];
const STATUS_ORDER = { available: 0, reserved: 1, 'on-loan': 2, 'in-repair': 3 };
const LABELS = { available: 'Available', reserved: 'Reserved', 'on-loan': 'On loan', 'in-repair': 'In repair' };

function Catalogue() {
  const [category, setCategory] = useState('All');
  const [query, setQuery] = useState('');
  const [sortBy, setSortBy] = useState('name');

  const search = query.trim().toLowerCase();
  const visibleTools = TOOLS
    .filter((tool) => (category === 'All' || tool.category === category)
      && tool.name.toLowerCase().includes(search))
    .toSorted((a, b) => (sortBy === 'status'
      && STATUS_ORDER[a.status] - STATUS_ORDER[b.status]) || a.name.localeCompare(b.name));

  function handleClear() {
    setCategory('All');
    setQuery('');
  }

  return (
    <>
      <label htmlFor="category-filter">Category</label>{' '}
      <select id="category-filter" value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="All">All</option>
        {CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
      </select>{' '}
      <label htmlFor="tool-search">Search</label>{' '}
      <input id="tool-search" value={query} onChange={(e) => setQuery(e.target.value)} />{' '}
      <label htmlFor="sort-order">Sort by</label>{' '}
      <select id="sort-order" value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="name">Name</option>
        <option value="status">Status</option>
      </select>
      <p role="status">Showing {visibleTools.length} of {TOOLS.length} tools</p>
      {visibleTools.length === 0 ? (
        <p>No tools match. <button type="button" onClick={handleClear}>Clear filters</button></p>
      ) : (
        <ul>
          {visibleTools.map((tool) => <li key={tool.id}>{tool.name}: {LABELS[tool.status]}</li>)}
        </ul>
      )}
    </>
  );
}

Three pieces of state, everything else derived. The sort comparator is compacted: when sorting by status and the statuses differ, the difference decides; otherwise (equal statuses, or sorting by name) || falls through to the name. If that one-liner reads as too clever for your team, the compareTools function from the sorting section is the clearer choice. Try a search for “saw”: the summary says “Showing 0 of 8 tools”, the message appears, and Clear filters brings everything back.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. The catalogue uses key={index}. A member expands the details panel of the second tool, then filters by category, and a different tool’s panel is now open. Why?

Filtering changed which tool sits at each position, but the keys (0, 1, 2…) stayed the same, so React kept each component, including its “expanded” state, and only gave it new props. The open panel stayed at position two while a different tool moved there. Using key={tool.id} ties the state to the tool instead of the position.

2. Why is calling TOOLS.sort() inside a component a bug even if the list looks right?

sort reorders the shared array in place during render. Every other component that reads TOOLS now sees the new order, and the render is no longer pure: its result depends on what earlier renders did to the data. Sort a copy with toSorted or [...TOOLS].sort().

3. How would you test that the catalogue’s keys are stable, without reading the code?

Give a row some state the data doesn’t control (expand a panel, type into a field, add a day to a loan), then change the list around it: filter, sort, or remove an item above it. The state should stay with its tool. Also type into a field inside a row: if it loses focus after each keystroke, the keys are being regenerated on every render. And keep the console open for React’s key warnings.

Key takeaways

  • Render lists with map; keep the callback short and remember return when you use braces.
  • Keys give list items an identity, so React keeps DOM and state with the right item. Use stable ids from the data; avoid indexes on changing lists and never generate keys during render.
  • Store filter, search and sort choices in state, and derive the visible list from the data on each render: filter, then sort a copy.
  • Always handle the empty result, and announce result counts with a role="status" element that is always rendered.

Priority: should know. Almost every screen has a list, so you should be able to build one with correct keys and derived filtering without thinking, and recognise index-key bugs in review. See Rendering Lists in the React documentation, and MDN’s Array.prototype.toSorted for browser support.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.