Flex Engineers
5 min read

A Full-Stack Tour of Modern React: Our Engineers' Twelve-Sandbox Growth Track

ReactRedux ToolkitTanStack QueryReact RouterFrontend DevelopmentTechnical Mentoring

A Full-Stack Tour of Modern React: Twelve Sandboxes, One Learning Arc

Most "learned React" repos are a pile of disconnected to-do apps. The one we ask engineers to build as part of our internal growth track is different by design: it's a sequence of self-contained, runnable projects, each isolating one piece of the React ecosystem an engineer needs to be fluent in before we staff them on client frontend work — 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 an engineer doesn't actually understand a data-fetching pattern until they've had to write the API it's talking to.

We walk new engineers through it the way we'd walk a client through it: not "look, I did a tutorial," but "here's the range of problems this team 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: engineers should be able to hand any single folder to a teammate 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 — the same decision our engineers have to make fresh on every new client project:

ApproachWhereWhen it's the right call
Lifted useState in a parentProjectManagementAppSmall, single-owner state tree; no cross-cutting concerns
useReducer + ContextContextAPIuseReducerRelated state transitions get complex, but you don't need time-travel debugging or middleware
Redux Toolkit (slices + thunks)BasicRedux → AdvancedReduxState 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 our engineers reach for in client 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, which is exactly the muscle memory we want before an engineer wires up auth on a client app.

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) means engineers deal 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 we 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 we make to engineers going through the program: it's the same instinct that keeps a production codebase debuggable — isolate the variable you're testing. When Authentication broke for the engineer who built it, 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 our clients depend on.

Takeaway: this repo isn't proof an engineer 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, which is exactly what our growth track is designed to produce before an engineer is client-facing.