CodingNeed.

React · Build and review an expense interface · 40 MIN

Model expense transitions with a reducer

Derive totals from the source records.

Duplicating a total in state introduces a synchronization problem: removing an expense may update the list but forget the total. Keep records as the source of truth and calculate the total during rendering. The reducer returns new arrays so React can observe changes. A discriminated union documents all supported transitions and a never branch exposes missing cases during strict type checking. IDs belong to records and stay stable across renders. This is a state-management lab; it does not persist records or authorize backend access.

The interface is a projection of state. Make each transition explicit so you can explain it.

Read the example

'use client';
import {useReducer} from 'react';
type Expense = {id:string; label:string; cents:number};
type Action = {type:'added'; expense:Expense} | {type:'removed'; id:string};
export function reducer(state:Expense[], action:Action):Expense[] {
  switch (action.type) {
    case 'added': return state.some(x=>x.id===action.expense.id) ? state : [...state, action.expense];
    case 'removed': return state.filter(x=>x.id!==action.id);
    default: { const unreachable:never=action; return unreachable; }
  }
}
export default function ExpenseList() {
  const [expenses, dispatch] = useReducer(reducer, []);
  const total = expenses.reduce((sum, e)=>sum + e.cents, 0);
  return <section><h2>Total: {(total / 100).toFixed(2)}</h2>
    <button onClick={()=>dispatch({type:'added',expense:{id:crypto.randomUUID(),label:'Book',cents:1200}})}>Add a book</button>
    <ul>{expenses.map(e=><li key={e.id}>{e.label}
      <button aria-label={'Remove '+e.label} onClick={()=>dispatch({type:'removed',id:e.id})}>Remove</button>
    </li>)}</ul>
  </section>;
}
Check the expected output
Render this component in a React application; verify the checklist below.

Your challenge

Create the component, add a custom expense form, and write reducer tests for add, duplicate ID and removal. Demonstrate that the original state array remains unchanged.

Solution cost: Rendering grows with the number of visible items. Each reducer update below copies its collection. time · O(n) for n records, excluding framework internals. space

Common trap

A component rendering successfully does not prove its data or accessibility behavior.

Study the project implementation
'use client';
import {useReducer} from 'react';
type Expense = {id:string; label:string; cents:number};
type Action = {type:'added'; expense:Expense} | {type:'removed'; id:string};
export function reducer(state:Expense[], action:Action):Expense[] {
  switch (action.type) {
    case 'added': return state.some(x=>x.id===action.expense.id) ? state : [...state, action.expense];
    case 'removed': return state.filter(x=>x.id!==action.id);
    default: { const unreachable:never=action; return unreachable; }
  }
}
export default function ExpenseList() {
  const [expenses, dispatch] = useReducer(reducer, []);
  const total = expenses.reduce((sum, e)=>sum + e.cents, 0);
  return <section><h2>Total: {(total / 100).toFixed(2)}</h2>
    <button onClick={()=>dispatch({type:'added',expense:{id:crypto.randomUUID(),label:'Book',cents:1200}})}>Add a book</button>
    <ul>{expenses.map(e=><li key={e.id}>{e.label}
      <button aria-label={'Remove '+e.label} onClick={()=>dispatch({type:'removed',id:e.id})}>Remove</button>
    </li>)}</ul>
  </section>;
}

Further reading: Official React documentation

Next lesson: Make input errors understandable

Essential cookies keep your account signed in. Optional analytics is not configured on this site. Your choice does not affect access to lessons.

Read the Privacy Policy. You can change this choice in the footer.