What you'll learn
Quick Answer
Start with projects that exercise state and rendering — a counter with history, a filterable list, a quiz — then move to ones needing effects and data fetching, then routing and shared state. Eight are listed below in order, each chosen because it forces one specific concept: derived state, lifting state, custom hooks, routing with URL parameters, or Context. Build fewer projects and take them further than the tutorial does.
Three Projects for State and Rendering
1. Counter with undo history. Sounds trivial, and the history is what makes it useful. Teaches the functional updater, immutable array updates, and why you cannot push to state.
Stretch: redo as well as undo, and a maximum history length. What it forces: every state change must produce a new array, so mutation bugs surface immediately.
2. Filterable, sortable product list. A list with a search box, category filter and sort dropdown. Teaches derived state — the single most valuable habit in React.
// Wrong: three sources of truth that drift apart
const [products, setProducts] = useState([]);
const [filtered, setFiltered] = useState([]);
// Right: one source, everything else computed during render
const [products, setProducts] = useState([]);
const [query, setQuery] = useState('');
const visible = products.filter(p => p.name.includes(query));Stretch: debounce the search input, and persist the filters in the URL query string.
3. Quiz with a timer and results. Questions from data, one at a time, score at the end. Teaches conditional rendering, useEffect cleanup for the timer, and moving between steps.
Stretch: shuffle the options, and show which answers were wrong. What it forces: a timer without clearInterval in the cleanup keeps running after unmount — a bug you will only understand once you cause it.
Three Projects for Effects and Data
4. GitHub profile viewer. Enter a username, show the profile and top repositories. Teaches useEffect with a dependency, the three UI states, and cleanup with AbortController.
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError(null);
fetch(`https://api.github.com/users/${username}`, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status === 404 ? 'User not found' : `HTTP ${r.status}`);
return r.json();
})
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') setError(e.message); })
.finally(() => setLoading(false));
return () => controller.abort(); // cancels if username changes fast
}, [username]);Note that fetch does not throw on a 404 — checking r.ok is required, and forgetting it is why the error state never appears.
5. Movie search with pagination. Teaches query parameters, loading more results, and keeping previous results visible while fetching. Stretch: infinite scroll with IntersectionObserver, and caching pages already fetched.
6. Weather dashboard with saved cities. Teaches combining fetched data with persisted local state, and writing a first custom hook. Stretch: extract useLocalStorage and useFetch as reusable hooks — this is the natural moment to learn why custom hooks exist.
Two Projects Worth Showing
7. Multi-page blog or docs site with routing. A list page, a detail page at /posts/:id, a 404 route, and a layout shared between them. Teaches React Router, URL parameters, nested routes and programmatic navigation.
Stretch: a search that updates the URL so results are shareable, and scroll restoration between pages. What it forces: treating the URL as state, which is a concept many React developers never properly learn.
8. Shopping cart with Context. Product listing, cart, quantity changes, running total, persisted across refreshes. Teaches Context for genuinely shared state, useReducer for related actions, and derived totals.
function cartReducer(state, action) {
switch (action.type) {
case 'add': return [...state, action.item];
case 'remove': return state.filter(i => i.id !== action.id);
case 'qty': return state.map(i =>
i.id === action.id ? { ...i, qty: action.qty } : i);
default: return state;
}
}
// Total is computed, never stored — it cannot drift out of sync
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);Stretch: split state and dispatch into separate contexts so components that only dispatch do not re-render on data changes.
These two are the ones to put on a resume. They are large enough to discuss for twenty minutes, which is what an interview actually requires.
What Turns a React Project Into a Portfolio Piece
Recruiters have seen many React to-do lists. These are the additions that distinguish yours, and each one is also an interview talking point.
- Handle all three states. Loading, error and empty. Most tutorial projects only handle success, and handling the other two is immediately visible.
- Make it work on a phone. Most people opening your link are on mobile. A layout that breaks below 400px undoes the impression instantly.
- Add keyboard support and labels. Every input with a
label, focus visible, modals closable with Escape. Almost no fresher portfolio does this, and interviewers notice. - Extract one custom hook. It proves you understand that hooks share logic rather than state.
- Deploy it. A live URL is what gets clicked; a repository is what might get read afterwards.
- Write the README. What it does, a screenshot, how to run it, and what you would improve. The last part signals self-awareness.
Do not build all eight. Build three properly. Ten shallow projects say you can follow instructions; three deep ones give you something to defend under questioning, which is what actually happens in the room.
Mistakes to Avoid While Building
Storing derived values in state. A total, a filtered list, an "is valid" flag — compute these during render. Every derived value in state is a value that can go stale.
Using the array index as a key. Fine for a static list, wrong for anything that can be reordered, filtered or deleted. The symptom is input values attaching to the wrong rows after a delete, which is baffling if you do not know the cause.
Mutating state. items.push(x) then setItems(items) does nothing, because React compares by reference and the array is the same object.
Fetching without cleanup. Navigate away mid-request and the response arrives for an unmounted component. Use AbortController.
Reaching for Redux immediately. Component state and Context handle far more than people expect. Adding a state library before you have felt the problem it solves produces code that is harder to follow, not easier.
Over-using useMemo and useCallback. They have their own cost and add noise. Apply them when profiling shows a real problem, or when a memoised child needs a stable reference — not by default.
