A Full-Stack Tour of Modern React: Twelve Sandboxes, One Learning Arc
Most "I learned React" repos are a pile of disconnected to-do apps. This one is different by design: it's a sequence of self-contained, runnable projects, each isolating one piece of the React ecosystem I needed to be fluent in — state modeling, side effects, global state, routing-as-data-loading, authentication, code splitting, and server-state caching. Several of them ship their own Express backend, because you don't actually understand a data-fetching pattern until you've had to write the API it's talking to.
I'm walking through it here the way I'd walk a client through it: not "look, I did a tutorial," but "here's the range of problems I can pick the right tool for."
The shape of the repo
09. ProjectManagementApp Vite + React 19 + Tailwind — component composition, lifted state
10. ContextAPIuseReducer Context + useReducer — avoiding prop drilling without Redux
11. useEffect Side-effect timing, cleanup, dependency arrays
12. QuizApp Timed state transitions, derived UI from state machines
19. FoodOrderApp React 19 + Context, cart logic, paired Node backend
20. BasicRedux Redux Toolkit fundamentals — slices, createSlice, useSelector
21. AdvancedRedux Thunks, async dispatch, cross-slice notifications
22. Router react-router-dom v6 — nested routes, loaders, actions
23. Authentication JWT auth end-to-end: bcrypt + JWT backend, token-expiry frontend
24. LazyLoading_Deployment lazy()/Suspense code splitting, Firebase Hosting config
25. Tanstack @tanstack/react-query replacing manual fetch/cache logic
Each folder is its own Vite or CRA app with its own package.json — deliberately not a monorepo. That's a decision, not an oversight: I wanted each concept isolated enough that I could hand any single folder to someone and have it run in under a minute, without cross-project dependency bleed.
State management, three different ways
The repo isn't "here's how to use Redux." It's a comparison of three legitimate ways to manage state, used where each one actually fits:
| Approach | Where | When it's the right call |
|---|---|---|
Lifted useState in a parent | ProjectManagementApp | Small, single-owner state tree; no cross-cutting concerns |
useReducer + Context | ContextAPIuseReducer | Related state transitions get complex, but you don't need time-travel debugging or middleware |
| Redux Toolkit (slices + thunks) | BasicRedux → AdvancedRedux | State is genuinely global (cart, UI notifications) and touched by many unrelated components |
ProjectManagementApp is a good example of the "don't reach for Redux by default" case — a single projectState object with selectedProjectId, projects, and tasks, all reducer-style updates via functional setState:
function handleAddTask(text) {
setProjectState((prevState) => {
const taskId = Math.random();
const newTask = { id: taskId, text, projectId: prevState.selectedProjectId };
return { ...prevState, tasks: [newTask, ...prevState.tasks] };
});
}
No reducer, no context, no library — because the whole tree is owned by one component and nothing outside App needs it. Compare that to AdvancedRedux's cart slice, where quantity, totals, and a UI notification banner all need to react to the same async event (a cart sync to a Firebase REST endpoint), and a hand-rolled thunk earns its keep:
export const sendCartData = (cart) => {
return async (dispatch) => {
dispatch(uiActions.showNotification({ status: 'pending', title: 'Sending...' }));
try {
await sendRequest();
dispatch(uiActions.showNotification({ status: 'success', title: 'Success!' }));
} catch (error) {
dispatch(uiActions.showNotification({ status: 'error', title: 'Error!' }));
}
};
};
That pattern — thunk dispatches a pending notification, awaits the request, dispatches success or error — is the same shape I reach for in production apps that need optimistic-ish UI feedback without a full optimistic-update system.
Routing as a data layer, not just a URL matcher
Router and Authentication both lean on React Router v6's loader/action APIs rather than useEffect-driven fetches inside components. The interesting part is the auth guard, because it's not just "is there a token" — it accounts for expiry:
export function getAuthToken() {
const token = localStorage.getItem("token");
if (!token) return null;
const tokenDuration = getTokenDuration();
if (tokenDuration < 0) return "EXPIRED";
return token;
}
export function checkAuthLoader() {
const token = getAuthToken();
if (!token) return redirect("/auth");
}
getTokenDuration diffs the stored expiration timestamp against now so a stale-but-present token in localStorage doesn't silently pass as valid — it's explicitly distinguished from "no token" so the UI can tell a user "you were logged out" instead of just bouncing them with no explanation. The backend side (bcryptjs + jsonwebtoken + Express) issues that token and its expiry on login, so the whole auth loop — issue, store, expire, redirect — is implemented end to end rather than mocked.
Killing manual fetch/cache logic with TanStack Query
The Tanstack folder is the clearest before/after in the repo. Earlier projects fetch data with raw fetch inside useEffect, manage isLoading/error state by hand, and have no request cancellation story. Tanstack replaces all of that:
export async function fetchEvents({ signal, searchTerm, max }) {
let url = 'http://localhost:3000/events';
if (searchTerm) url += '?search=' + searchTerm;
const response = await fetch(url, { signal });
if (!response.ok) {
const error = new Error('An error occurred while fetching the events');
error.code = response.status;
error.info = await response.json();
throw error;
}
const { events } = await response.json();
return events;
}
The signal parameter comes straight from useQuery's query function — TanStack Query wires up AbortController cancellation automatically when a component unmounts or a query key changes mid-flight, which is the exact class of race condition (stale response overwriting fresh state) that hand-rolled useEffect fetching gets wrong constantly. Pairing it with a real Express backend (rather than a mock) meant dealing with actual network latency, actual 404s, and actual cache invalidation after mutations like createNewEvent and deleteEvent — not the happy path a mocked API tends to produce.
Why isolate every concept instead of building one big app
The deliberate choice to keep these as twelve separate apps rather than one showcase app is itself the point I'd make to a client: it's the same instinct that keeps a production codebase debuggable — isolate the variable you're testing. When Authentication broke, the failure was in JWT expiry logic, not tangled up with Redux selectors or query cache invalidation from an unrelated feature. That discipline — one concern, one small blast radius, verify it in isolation before it touches the rest of a real app — is what scales from a learning repo to a production codebase.
Takeaway: this repo isn't proof I can follow a tutorial — it's a working reference for when to use useState vs useReducer vs Redux, how router loaders replace effect-based data fetching, why token expiry needs explicit handling instead of a boolean check, and what TanStack Query buys you over raw fetch. Client work rarely needs all of these at once, but it constantly needs the judgment to pick the right one — and that judgment only comes from having built the wrong one first and felt where it broke.