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 →