Skip to content

Rendering Lists

In Module 02 you learned the basics of rendering lists with map and key. This lesson goes deeper — dynamic lists that come from state, filtering, sorting, and the empty state pattern.

When the list comes from state, every change to state re-renders the list automatically:

const [transactions, setTransactions] = useState([]);
function addTransaction(tx) {
setTransactions(prev => [...prev, { ...tx, id: crypto.randomUUID() }]);
}
function deleteTransaction(id) {
setTransactions(prev => prev.filter(t => t.id !== id));
}

[...prev, newItem] creates a new array with the item appended — never mutating the existing array. prev.filter(...) returns a new array without the removed item.

Always handle the case where the list is empty:

export default function TransactionList({ transactions, onDelete }) {
if (transactions.length === 0) {
return <p className="empty">No transactions yet.</p>;
}
return (
<ul className="transaction-list">
{transactions.map(tx => (
<li key={tx.id} className="transaction-row">
<span className="tx-date">{tx.date}</span>
<span className="tx-desc">{tx.description || 'No description'}</span>
<span className="tx-amount">${tx.amount.toFixed(2)}</span>
<button onClick={() => onDelete(tx.id)}>×</button>
</li>
))}
</ul>
);
}

The early return shows the empty message when there is nothing to display. The list only renders when there is something to show.

In ZeroBudget, transactions belong to categories. Each CategoryCard shows only the transactions for its category:

export default function CategoryCard({ category, transactions, onAddTransaction, onDeleteTransaction }) {
const categoryTransactions = transactions.filter(tx => tx.categoryId === category.id);
return (
<div className="category-card card">
<h3>{category.name}</h3>
<TransactionList
transactions={categoryTransactions}
onDelete={onDeleteTransaction}
/>
</div>
);
}

Filter the array before passing it to the list component. The filtering is a computation — it does not need to be stored in state. This is derived state: a value computed from existing state that does not need its own useState.

Sort with Array.prototype.sort before rendering. Always sort a copy — sort mutates the original array:

const sorted = [...transactions]
.sort((a, b) => new Date(b.date) - new Date(a.date));

The spread [...transactions] creates a copy. Then sort descending by date. The original transactions array in state is unchanged.

  1. Add transactions state to App.jsx with useState([]). Write addTransaction and deleteTransaction functions.
  2. Create TransactionList that accepts a transactions array and an onDelete callback. Show an empty message when the list is empty.
  3. In CategoryCard, filter transactions by categoryId and pass the result to TransactionList.
  4. Test by adding a transaction through the form and confirming it appears in the correct category card.
  • Dynamic lists come from state — updates automatically trigger re-renders.
  • Use [...prev, newItem] to add and prev.filter(...) to remove — never mutate the array in state.
  • Always handle the empty state with a message or fallback UI.
  • Filter and sort arrays before passing them to rendering components — these are derived computations, not separate state.