React Projects
7 Project Briefs โ Beginner to Capstone
Table of Contents
- Todo List
- Weather App
- Notes App with Local Storage Persistence
- Book Search with Routing and Debounced Search
- Kanban Board with Drag-and-Drop
- Shopping Cart with Global State
- Dashboard Capstone โ React Query, Next.js, Charts
Todo List
Unlike the chapter challenges, this is a project brief, not a graded exercise โ there's no single correct solution file. Instead, you get requirements, a suggested structure, and stretch goals; how you actually build it is up to you. A todo list might seem simple, but it touches almost every Fundamentals concept at once: state, lists, conditional rendering, controlled inputs, and component composition, all working together in one small app.
Requirements
- A text input and "Add" button for entering a new todo โ pressing Enter should also work, not just clicking the button.
- Each todo displays its text and has a way to mark it complete (e.g. a checkbox) and a way to delete it.
- Completed todos should look visually distinct from incomplete ones (e.g. strikethrough text, dimmed color).
- The input should clear itself after a todo is successfully added.
- Adding an empty/blank todo should be prevented.
- If the list is empty, show a friendly message (e.g. "No todos yet โ add one above!") instead of an empty list.
Suggested Component Breakdown
This isn't the only valid structure โ it's a starting point. The key decision worth sticking to: the array of todos itself should live in TodoApp (the lowest common ancestor of the form and the list), with TodoForm and TodoItem each receiving only the props and callbacks they specifically need, exactly as covered in Fundamentals Chapter 10.
A Reasonable Build Order
- Get a static version working first โ hardcode a small array of 2โ3 todo objects (
{ id, text, completed }) and render them withTodoList/TodoItembefore wiring up any state changes. - Move that array into
useStateinTodoApp, so it's now real state instead of a hardcoded constant. - Wire up
TodoFormto add a new todo object to the array on submit, generating a uniqueidfor each one (e.g.Date.now()is a quick option for a small project like this). - Add the "toggle complete" behavior โ clicking a todo's checkbox should flip its
completedvalue without affecting any other todo in the array. - Add the delete behavior โ removing exactly one todo from the array by its
id, leaving the rest unchanged. - Add the empty-state message and the blank-input guard last, once the core add/toggle/delete flow is solid.
- Add an "edit" mode โ double-clicking a todo's text turns it into an editable input.
- Add filter buttons (All / Active / Completed) above the list.
- Show a live count of remaining (incomplete) todos.
- Persist the list to
localStorageso it survives a page refresh (a preview of Intermediate Chapter 6's data-handling patterns). - Add a "Clear completed" button that removes every completed todo at once.
Weather App
Project 1 worked entirely with local state โ nothing ever left the browser. This project introduces a real external dependency: a weather API. That single change brings a whole new category of things to handle correctly โ the data isn't there immediately, the request can fail, and the UI needs to make sense in all three states (loading, error, and loaded), not just the happy path.
Requirements
- A text input where the user types a city name, with a search button (or Enter-to-search).
- While a search is in progress, show a clear loading indicator instead of stale or blank content.
- If the city can't be found, or the request fails, show a readable error message โ not a blank screen or a crash.
- On success, display at minimum: the city name, current temperature, and a short description (e.g. "Clear sky," "Light rain").
- Searching for a new city should correctly replace the previously shown result, not append to it.
- An empty search input should not trigger a request at all.
Suggested Component Breakdown
The status state is the important design decision here โ rather than separate isLoading/hasError booleans that could theoretically contradict each other, a single status value ("idle" | "loading" | "error" | "success") guarantees the UI is always in exactly one well-defined state, using the lookup-object/conditional patterns from Fundamentals Chapter 5.
A Reasonable Build Order
- Build
SearchFormfirst with just a controlled input and a submit handler that logs the typed city name โ no fetching yet. - Manually call the geocoding endpoint once in the browser/Postman/curl for a known city, to see the actual JSON shape you'll be working with before writing any fetch code.
- Wire up the geocoding fetch inside an async function triggered on form submit, storing the resolved coordinates in state.
- Add the second fetch for the actual weather data once coordinates are available, setting
statusto"loading"before it starts and"success"once it resolves. - Wrap both fetches in a try/catch, setting
statusto"error"(with a stored error message) if either one fails. - Build out
WeatherDisplaylast, rendering different JSX for each of the four status values.
- Show a multi-day forecast, not just current conditions.
- Add a Celsius/Fahrenheit toggle.
- Use the browser's Geolocation API to default to the user's current location on first load.
- Cache recent searches so re-searching the same city doesn't re-fetch immediately.
- Add simple weather-appropriate icons or background colors based on the description returned.
Notes App with Local Storage Persistence
Project 1's todos vanished the moment the page reloaded โ every piece of state was reset to nothing. This project adds persistence: saving the current data to the browser's localStorage on every change, and reading it back when the app first loads, so a note typed today is still there tomorrow. It's also the first project with a genuine "edit an existing item in place" flow, rather than just adding/removing whole items.
Requirements
- A way to create a new note with at least a title and a body of text.
- All existing notes are listed, each showing its title and a preview (or full body) of its content.
- Clicking a note lets you edit its title/body in place, saving the changes back to the same note.
- Each note can be deleted individually.
- The entire notes list is saved to
localStoragewhenever it changes โ adding, editing, or deleting a note. - On page load/refresh, any previously saved notes are read back from
localStorageand displayed โ not lost.
Suggested Component Breakdown
NoteItem is the trickiest piece โ it needs its own small piece of local state (something like isEditing) to decide whether it's currently showing the note's static text or an editable form for it. That's a perfectly reasonable case for state living inside NoteItem itself rather than being lifted up, since no other component needs to know whether one particular note is currently being edited.
A Reasonable Build Order
- Build the add/list/delete flow first, exactly like Project 1's todo list, with notes only living in memory (no
localStorageyet). - Add a
useEffectinNotesAppwith the notes array as its dependency, callinglocalStorage.setItem("notes", JSON.stringify(notes))every time it changes. - Add a second
useEffect(with an empty dependency array, running once on mount) that readslocalStorage.getItem("notes"), parses it withJSON.parse, and sets it as the initial notes state โ handling the case where nothing's been saved yet. - Refresh the page after adding a few notes to confirm persistence is actually working before building anything else.
- Add edit mode to
NoteItemlast โ its ownisEditingstate, a controlled form shown only while editing, and a "Save" action that updates the matching note in the parent's array.
localStorage.setItem can't store a JavaScript array or object directly โ it needs to be converted to a string first with JSON.stringify, and converted back with JSON.parse when reading it. Forgetting either step is one of the most common bugs in a project like this โ usually showing up as "[object Object]" being stored literally, or a crash when trying to .map() over a raw string.
- Extract the load/save logic into a reusable
useLocalStoragecustom hook (a preview of Intermediate Chapter 4). - Add a search box that filters the visible notes by title/content as you type.
- Store and display a "last edited" timestamp per note.
- Add a confirmation step before deleting a note, to avoid accidental data loss.
- Support basic Markdown-style formatting in the note body (e.g. rendering
**bold**as bold text).
Book Search with Routing and Debounced Search
This project reaches slightly ahead of where the chapter-by-chapter courses are at โ it needs two ideas that don't have their own Fundamentals chapter yet: routing (multiple "pages" in a single-page app) and debouncing (delaying a search request until typing actually pauses). Both get a working primer below; React Router gets its proper full chapter in Intermediate Chapter 5, and this project is a hands-on first taste of it ahead of that.
npm install react-router-dom. Four pieces cover this whole project: <BrowserRouter> wraps the app once, at the top; <Routes>/<Route path="..." element={...}> define which component renders for which URL; <Link to="..."></Link> navigates without a full page reload; and useParams() reads a dynamic piece of the URL (like a book's id) inside the component that route renders.
setTimeout inside a useEffect, with the cleanup function (from Fundamentals Chapter 8) cancelling the pending timeout if the user types again before it fires.
Suggested Data Source
The Open Library Search API is free and needs no API key, similar to Open-Meteo in Project 2 โ https://openlibrary.org/search.json?q=<query> returns a list of matching books, and https://openlibrary.org/works/<id>.json returns details for one specific book.
Requirements
- A search page at
/with a text input โ typing should debounce before firing a search request, not fire on every keystroke. - Search results render as a list, each showing at least a title and author, and linking to a detail page for that specific book.
- A detail page at a URL like
/book/:id, fetching and showing more information about that one specific book based on the id in the URL. - The detail page includes a way to navigate back to the search page (a
<Link>, not the browser's own back button only). - Both pages handle loading and error states properly, reusing the status-based pattern from Project 2.
- Navigating between the two pages does not cause a full page reload โ that's the whole point of using React Router instead of plain
<a>tags.
Suggested Component Breakdown
A Reasonable Build Order
- Set up
react-router-domwith two placeholder page components and a working<Link>between them, before any fetching exists at all โ confirm navigation itself works first. - Build the search input with a non-debounced fetch (firing on every keystroke) to confirm the API call and result rendering work correctly.
- Convert the search effect to the debounced version, confirming with the browser's network tab that requests are no longer firing on every single keystroke.
- Make each result item a
<Link to={`/book/${id}`}>, carrying the right id into the URL. - Build
BookDetailPage, reading the id withuseParams()and fetching that specific book's details in auseEffectkeyed on the id. - Add loading/error handling to both pages last, once the core search โ detail flow works end to end.
- Extract the debounce logic into a reusable
useDebouncecustom hook (another preview of Intermediate Chapter 4). - Add pagination or "load more" to the search results.
- Persist a small "recently viewed books" list to
localStorage, shown on the search page. - Show a loading skeleton instead of a plain "Loading..." message.
- Add a 404-style fallback route for any URL that doesn't match either page.
Kanban Board with Drag-and-Drop
Every project so far has worked with a flat array โ a list of todos, a list of notes. A Kanban board's state is one level more complex: an object where each key holds its own array (one per column), and the core interaction โ dragging a card from one column to another โ means immutably removing an item from one array and adding it to a different one, in the same update. This project also introduces the browser's native drag-and-drop events, which React doesn't wrap in anything special โ they're used directly, the same way onClick always has been.
draggable on the card itself; onDragStart (fires when dragging begins โ store which card and which column it came from); onDragOver on each column (must call e.preventDefault(), or the column will never accept a drop); and onDrop on each column (where the actual state update happens, moving the card into that column).
Requirements
- At least three columns (e.g. "To Do," "In Progress," "Done"), each showing its own list of cards.
- A way to add a new card with some text, landing in a specific column.
- Dragging a card from one column and dropping it on another moves it there โ removed from the original column's list, added to the new one.
- A card can be deleted from whichever column it's currently in.
- The column currently being dragged over should look visually different (e.g. a highlighted border) while a card is hovering over it.
- Dropping a card back into its original column should not duplicate it or lose it.
Suggested Component Breakdown
A reasonable shape for the state itself: { todo: [{ id, text }, ...], inProgress: [...], done: [...] }. KanbanBoard owns this whole object โ exactly the "lowest common ancestor" reasoning from Fundamentals Chapter 10, since moving a card between columns is fundamentally a change that affects two columns (siblings) at once, which only their shared parent can coordinate.
A Reasonable Build Order
- Hardcode the columns object with a few sample cards in each, and get all three columns rendering their cards correctly with
.map()before touching drag-and-drop at all. - Move the hardcoded object into
useState, and buildAddCardFormso new cards can be added to a chosen column's array. - Add
draggableandonDragStarttoCard, storing the card's id and its current column name (state, a ref, ore.dataTransferall work) somewhereonDropcan read it from later. - Add
onDragOver(withe.preventDefault()) andonDroptoColumn. On drop, build a new columns object: filter the card out of its source column's array, and add it to the destination column's array. - Add the drag-over highlight โ a small piece of state in each
Column(or lifted up, tracking which column id is currently being dragged over) toggled byonDragEnter/onDragLeave. - Add the delete button per card last, once the drag-and-drop flow is solid.
e.preventDefault() inside onDragOver is the single most common reason a drop silently does nothing โ the browser's default behavior for drag-over is to reject the drop entirely unless that default is explicitly cancelled. Separately, building the new columns object by mutating the existing arrays in place (e.g. .push() or .splice() directly on a state array) won't reliably trigger a re-render โ always build new arrays (.filter(), spread) and a new outer object, the same immutability rule from Fundamentals Chapter 3, just one level deeper this time.
- Support reordering cards within the same column, not just moving between columns.
- Persist the whole board to
localStorage, same pattern as Project 3's notes. - Swap the native drag-and-drop API for a dedicated library (e.g.
@dnd-kit/core), which adds proper keyboard/accessibility support that the native API lacks. - Let columns themselves be added, renamed, or removed, rather than being fixed at three.
- Add a card-detail view (click to expand a card into a longer description, due date, etc.).
Shopping Cart with Global State
Every previous project's state stayed within one small cluster of components, close enough together that lifting state up (Fundamentals Chapter 10) was a clean fit. A shopping cart is different: a product grid, a cart icon in the page header, and a full cart page all need to read or update the same cart โ and they're nowhere near each other in the component tree. Lifting the cart all the way up to App and passing it down through every layer in between would mean prop drilling through components that have nothing to do with the cart at all. This is exactly the situation the Context API exists for, previewed here ahead of its full treatment in Intermediate Chapter 2.
createContext() creates a context object; wrapping part of the tree in <CartContext.Provider value={...}> makes that value readable by any component nested inside it, no matter how deep, via useContext(CartContext) โ with no props passed through the components in between at all.
Requirements
- A product listing (hardcoded data is fine) with name, price, and an "Add to Cart" button on each.
- A persistent cart indicator (e.g. in a header) showing the total number of items, visible regardless of which "page" is currently shown.
- Adding the same product a second time increases its quantity in the cart, rather than creating a duplicate entry.
- A cart view listing every item with its quantity, a per-item subtotal, and a running total for the whole cart.
- A way to remove an item from the cart entirely, and a way to change an item's quantity directly.
- Every piece of this (product grid, header badge, cart view) reads from the same single source of cart state โ no two components should ever show conflicting cart data.
Suggested Component Breakdown
Notice that Header and ProductList are siblings, several layers removed from CartPage โ exactly the layout where plain prop-passing would mean threading the cart through components that don't use it themselves. CartProvider wraps all three, and every consumer below it reaches the cart directly via useContext.
A Reasonable Build Order
- Build the static product grid and a non-functional cart page shell first, with no Context at all yet.
- Create
CartContextandCartProvider, holding just theitemsarray inuseState, and wrap the whole app in it. - Write
addToCart: check whether the product is already in the array; if so, increase that entry's quantity, otherwise add a new entry with quantity 1 โ both cases building a new array, never mutating the existing one. - Wire
ProductCard's button to calladdToCartviauseContext, and confirm the cart's contents are correct by logging them, before building any UI for the cart itself. - Build
CartBadgenext โ it only needs a derived total (sum of every item's quantity), read from the same context. - Build out the full
CartPage/CartItemviews last, adding remove and quantity-update actions to the context's value alongsideaddToCart.
addToCart always appending a new entry, so adding the same product three times produces three separate rows instead of one row with quantity 3. Always check first โ find an existing entry with a matching product id, and only push a brand-new entry if none was found.
- Persist the cart to
localStorage, same pattern as Project 3, so it survives a refresh. - Replace the hand-rolled Context with a small state-management library โ Zustand or Redux Toolkit, both covered properly in Advanced Chapter 1 โ and compare how much boilerplate each removes versus plain Context.
- Add quantity +/- stepper buttons instead of a raw number input.
- Add a simple coupon-code field that applies a percentage discount to the total.
- Add a checkout confirmation step that clears the cart afterward.
Dashboard Capstone โ React Query, Next.js, Charts
npx create-next-app@latest. The headline difference from Vite: pages are defined by the file system rather than by code you write yourself โ a file at app/page.js becomes the / route, and a folder named app/coin/[id]/page.js becomes a dynamic route matching /coin/anything, with anything readable inside that page via Next's routing hooks. Everything learned about components, props, and state still applies unchanged โ Next.js only changes how a component becomes a "page."
npm install @tanstack/react-query, and wrap the app once in a <QueryClientProvider>. From there, useQuery replaces the entire "useState + useEffect + manual loading/error flags" pattern from earlier projects in one hook call:
queryKey, and can automatically refetch it later โ all without a single useEffect written by hand.
Suggested Data Source
The CoinGecko API is free and needs no API key for its public market-data endpoints โ a good fit for a dashboard with multiple live-updating widgets and a price history chart per item.
Requirements
- An overview page listing several items (e.g. top cryptocurrencies) with at least name, current price, and 24h change, fetched with
useQuery. - Clicking an item navigates (via Next.js routing) to a detail page for that specific item, at a dynamic URL.
- The detail page fetches and renders a price-history chart using Recharts (a line chart is enough).
- Loading and error states on both pages are handled entirely through React Query's
isLoading/isErrorflags โ no manually-managed loading state alongside it. - The overview page's data refreshes automatically after some interval (React Query's
refetchIntervaloption) without a manual page reload. - The layout reads reasonably on both a desktop-width and a narrow/mobile-width viewport.
Suggested Component Breakdown
A Reasonable Build Order
- Scaffold the Next.js app and confirm the default starter page runs, before changing anything.
- Install React Query, set up
QueryClientProviderin the root layout, and get oneuseQuerycall successfully logging fetched data to the console. - Build
CoinList/CoinCard, rendering real data from that query, each card linking to its own dynamic detail route. - Build the dynamic route page, reading the id from the URL and firing a second
useQueryfor that specific item's history data. - Install Recharts and get a
<LineChart>rendering the fetched price-history data on the detail page. - Add
refetchIntervalto the overview query last, and confirm (via the network tab) that it's actually refetching on its own over time.
useState for loading/error on top of the isLoading/isError React Query already provides. That duplicates logic React Query is specifically meant to remove; lean entirely on the flags useQuery returns instead.
- Add a Next.js API route as a thin proxy to the external API, avoiding any rate-limit/CORS concerns on the client.
- Add a search/filter box on the overview page.
- Add a second chart type (e.g. a bar chart comparing 24h change across items).
- Add a dark/light theme toggle, persisted across visits.
- Deploy the finished app to Vercel โ Next.js's own hosting platform, built for exactly this kind of project.