DEV Community

Cover image for From Scratch to a Million Users: An Uncle Teaches React Architecture
surajrkhonde
surajrkhonde

Posted on

From Scratch to a Million Users: An Uncle Teaches React Architecture

A long chat between Uncle (front-end architect) and Nephew (excited, slightly overconfident, about to build "the next big thing"). Grab chai, this one's long.


Part 0: The Trap Every Junior Falls Into

πŸ‘¦ Nephew: Uncle! I'm starting a new React app today. Give me five minutes, I'll npx create-react-app and be coding by lunch.

πŸ‘¨β€πŸ¦³ Uncle: Sit down. Before you write one line of code, answer me this β€” are you building a house, or are you building a table?

πŸ‘¦ Nephew: ...What?

πŸ‘¨β€πŸ¦³ Uncle: A table, you build once, it's done, nobody adds a room to a table. A house, you build knowing someday someone will want to add a bedroom, knock down a wall, put solar panels on the roof. Most people think they're building a table β€” "it's just a small app" β€” and then two years later it's a house with no foundation, and every new room they add cracks three old ones.

πŸ‘¦ Nephew: So how do I know which one I'm building?

πŸ‘¨β€πŸ¦³ Uncle: Simple rule β€” if there is even a 30% chance this app has more than 10 pages, more than one developer, or lives longer than 6 months, you build it like a house from day one. The cost of "over-engineering" a small app by 10% is nothing compared to the cost of rebuilding a large app from zero because nobody planned for growth. Today, I'm teaching you to think like the house builder. Every decision we make β€” starting with the very first command you type in your terminal β€” we make with "what happens when this has 100 pages" in mind.


Part 1: The First Decision β€” Choosing Your Build Tool

πŸ‘¦ Nephew: Okay so first command. create-react-app, right? Everyone uses it.

πŸ‘¨β€πŸ¦³ Uncle: Everyone used to use it. Let me tell you why it's basically retired now, and what replaced it, and why.

Create React App (CRA)

  • Built on Webpack under the hood, fully configured for you, zero config needed to start.
  • The problem: as your app grows, Webpack has to bundle your entire dependency tree before it can show you a single page during development. On a small app, you won't notice. On a 100-page app with hundreds of components, your dev server startup and hot-reload times start crawling β€” I've seen CRA apps take 30-60 seconds just to reload after a one-line change.
  • CRA is also, as of a while back, no longer actively maintained by the React team the way it used to be. Officially, the React docs don't recommend it anymore for new projects.

πŸ‘¦ Nephew: So it's dead?

πŸ‘¨β€πŸ¦³ Uncle: For new projects, treat it as retired. If you inherit an old CRA app, that's a different conversation β€” you migrate carefully, you don't rewrite in a panic.

Vite

  • Built differently at the core: during development, it doesn't bundle your whole app upfront. It serves your modules over native ES modules directly to the browser, and only compiles files on demand as you actually navigate to them, using a tool called esbuild (written in Go, extremely fast) for that on-demand compilation.
  • Why this matters for you: dev server startup is near-instant even on huge apps, because it's not bundling everything first. Hot reload is near-instant too, because it only has to reprocess the one file you touched.
  • For production builds, Vite switches to Rollup under the hood, which produces smaller, more optimized bundles than Webpack typically does out of the box.
  • The trade-off: Vite is younger than Webpack, so some very old or obscure Webpack-specific plugins might not have a Vite equivalent yet. In practice, for 95% of apps today, this isn't a real blocker anymore β€” the ecosystem has caught up a lot.

πŸ‘¦ Nephew: So Vite for everything?

πŸ‘¨β€πŸ¦³ Uncle: Not so fast. One more option.

Next.js (when you need more than "just React")

  • Next.js isn't just a build tool, it's a full framework on top of React β€” it gives you file-based routing, server-side rendering (SSR), static site generation (SSG), API routes, image optimization built in.
  • When to reach for it: if SEO matters (public marketing pages, blogs, e-commerce product pages that need to be indexed well by Google), or if you need content to be fast on the very first load without waiting for a huge JS bundle to download and run, Next.js's server rendering wins clearly.
  • When NOT to reach for it: a pure internal dashboard, an admin panel, an app behind a login wall where SEO is irrelevant β€” Next.js adds real complexity (server concerns, hydration issues, deployment considerations) for zero benefit in that case. Use Vite + React there, plain and simple.

Uncle's rule of thumb:

If the app is public-facing and needs to be found on Google or load instantly for anonymous users β†’ Next.js.
If the app is behind login, internal, dashboard-like, or a tool β†’ Vite + React.
If someone suggests CRA for a new project in 2026 β†’ gently correct them, then buy them a coffee, no hard feelings.

πŸ‘¦ Nephew: Got it. Vite it is, since we're building a dashboard-style product.

πŸ‘¨β€πŸ¦³ Uncle: Good. First decision made correctly. Now β€” the harder decisions.


Part 2: Folder Structure β€” Building the House So Rooms Don't Collide

πŸ‘¦ Nephew: Okay, project's created. Where do I put my files?

πŸ‘¨β€πŸ¦³ Uncle: This is where 90% of "our codebase is a mess" complaints are actually born. Let me show you the wrong way first, since you'll see it everywhere.

The trap β€” organizing "by type":

src/
  components/
    Button.jsx
    UserCard.jsx
    OrderTable.jsx
    InvoiceForm.jsx
    ProductGrid.jsx
    ... (200 more files, all mixed together)
  hooks/
    useUser.js
    useOrders.js
    useInvoices.js
    ... (100 more)
  pages/
    UserPage.jsx
    OrderPage.jsx
    ...
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: What's wrong with this? It looks organized.

πŸ‘¨β€πŸ¦³ Uncle: It looks organized on day one with 10 files. On day 200, with 10 developers, this becomes chaos β€” because to understand "how does the Orders feature work," you now have to open three different top-level folders and mentally reassemble the puzzle. And here's the real killer: if you ever need to delete the Orders feature entirely β€” say the business decides to sunset it β€” you now have to hunt through components/, hooks/, pages/, and probably three other folders, deleting files one by one, terrified you missed one and left a dead import somewhere.

The house-builder way β€” organizing "by feature":

src/
  features/
    orders/
      components/
        OrderTable.jsx
        OrderRow.jsx
      hooks/
        useOrders.js
      api/
        ordersApi.js
      OrdersPage.jsx
      index.js          <- the ONLY file other features are allowed to import from
    invoices/
      components/
      hooks/
      api/
      InvoicesPage.jsx
      index.js
    users/
      ...
  shared/
    components/         <- truly generic stuff: Button, Modal, Input
    hooks/               <- truly generic stuff: useDebounce, useLocalStorage
    utils/
  app/
    store.js
    router.jsx
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: Ohh β€” so each feature is like its own little room in the house, self-contained.

πŸ‘¨β€πŸ¦³ Uncle: Exactly. And here's the rule that makes this actually work, not just look nice: a feature folder can only be imported through its index.js. No other feature is allowed to reach directly into orders/components/OrderRow.jsx. If invoices needs something from orders, it imports from orders/index.js, and orders/index.js decides what it's willing to expose. This one rule is what makes a feature genuinely deletable β€” if tomorrow the "Orders" feature gets killed by product, you delete the orders/ folder, and since nothing reached inside it directly, nothing else breaks. You just remove the one line that mounted its route.

πŸ‘¦ Nephew: That's the "add and remove without fear" thing you mentioned!

πŸ‘¨β€πŸ¦³ Uncle: Exactly. This is what makes a codebase feel like Lego blocks instead of a spider web. Add a feature folder, wire it into the router, done. Remove a feature folder, unwire it from the router, done. No archaeology required.


Part 3: Data Fetching and the Stale Data Nightmare β€” RTK Query

πŸ‘¦ Nephew: Now the API stuff. Right now I'm just doing useEffect with fetch everywhere.

πŸ‘¨β€πŸ¦³ Uncle: (sighs the sigh of a thousand code reviews) Let me guess your current pain points without even seeing your code: duplicate loading states copy-pasted everywhere, the same data fetched twice on two different pages because nobody's sharing it, and after a user updates something, they have to manually refresh the page to see the change?

πŸ‘¦ Nephew: ...Uncle, are you reading my screen right now?

πŸ‘¨β€πŸ¦³ Uncle: No, I've just reviewed this exact bug a hundred times. This is the most common front-end pain, and it has a name: stale data. You update something on the server, but the UI is still showing the old cached view because nobody told it to refresh. This is exactly the same "cache invalidation" ghost we talked about on the backend side β€” except now it's living in your browser tab.

Enter RTK Query (part of Redux Toolkit) β€” it's not "just another way to fetch data," it's a caching layer built specifically to solve this.

How it thinks

RTK Query organizes your API around two ideas: queries (reading data) and mutations (changing data), and both are tagged with cache tags representing what kind of data they touch.

// features/orders/api/ordersApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const ordersApi = createApi({
  reducerPath: 'ordersApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/orders' }),
  tagTypes: ['Order'],
  endpoints: (builder) => ({
    getOrders: builder.query({
      query: () => '/',
      providesTags: ['Order'], // "this data IS the Order data"
    }),
    getOrderById: builder.query({
      query: (id) => `/${id}`,
      providesTags: (result, error, id) => [{ type: 'Order', id }],
    }),
    updateOrder: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Order', id }, 'Order'],
      // "after this mutation succeeds, throw away the cached Order data"
    }),
  }),
});

export const { useGetOrdersQuery, useGetOrderByIdQuery, useUpdateOrderMutation } = ordersApi;
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So invalidatesTags is the magic word?

πŸ‘¨β€πŸ¦³ Uncle: That's the whole trick. When updateOrder succeeds, RTK Query says "anything tagged Order is now suspicious, go refetch it." Every component anywhere in your app using useGetOrdersQuery or useGetOrderByIdQuery automatically refetches, without you writing a single line of "refresh" logic. The user sees fresh data everywhere, instantly, because you described the relationship between data once, and RTK Query enforces it forever.

In your component, using it is almost embarrassingly simple:

function OrdersPage() {
  const { data: orders, isLoading, isError } = useGetOrdersQuery();
  const [updateOrder] = useUpdateOrderMutation();

  if (isLoading) return <OrdersSkeleton />;
  if (isError) return <ErrorState />;

  return orders.map(order => (
    <OrderRow
      key={order.id}
      order={order}
      onMarkPaid={() => updateOrder({ id: order.id, status: 'paid' })}
    />
  ));
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: Wait, where's the useEffect? Where's the loading state I manually track with useState?

πŸ‘¨β€πŸ¦³ Uncle: Gone. RTK Query gives you isLoading, isFetching, isError, data, all managed for you, plus deduplication β€” if two components on the same page both call useGetOrdersQuery at the same time, RTK Query is smart enough to make one network request and share the result, not two. Your junior self would've fired two identical API calls without even noticing.


Part 4: Loading Fast vs Feeling Fast β€” Skeletons and Perceived Performance

πŸ‘¦ Nephew: Okay so isLoading β€” I just show "Loading..." right?

πŸ‘¨β€πŸ¦³ Uncle: You can. But let me tell you a secret about human psychology first. Two apps can load in the exact same amount of time β€” say 800 milliseconds β€” and one will feel instant while the other feels sluggish. The difference isn't speed. It's perceived speed.

πŸ‘¦ Nephew: How can the same time feel different?

πŸ‘¨β€πŸ¦³ Uncle: Because a blank white screen with a spinner tells your brain "nothing is happening, I'm waiting." A skeleton screen β€” a gray outline shaped exactly like the content that's about to appear β€” tells your brain "the content is basically already here, it's just finishing loading." It's a magic trick, but it works, and every serious product (LinkedIn, YouTube, Facebook) uses it deliberately.

function OrdersSkeleton() {
  return (
    <div className="space-y-3">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="animate-pulse flex gap-4 p-4 border rounded">
          <div className="h-4 w-1/4 bg-gray-200 rounded" />
          <div className="h-4 w-1/3 bg-gray-200 rounded" />
          <div className="h-4 w-1/6 bg-gray-200 rounded" />
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So it's basically a ghost of the real content.

πŸ‘¨β€πŸ¦³ Uncle: A friendly ghost. It even matches the exact shape β€” table rows if it's a table, card outlines if it's a grid β€” so there's no jarring "layout jump" when the real data pops in. That jump, by the way, has a real name β€” Cumulative Layout Shift β€” and it's actually a metric Google penalizes you for in search rankings. So skeletons aren't just nice UX, they're an SEO and performance metric too.


Part 5: Making the App Actually Small β€” Code Splitting

πŸ‘¦ Nephew: Now, our 100-page app β€” do we really send the code for all 100 pages to every single user on their very first visit?

πŸ‘¨β€πŸ¦³ Uncle: If you do, congratulations, you've built the frontend equivalent of forcing every restaurant customer to eat the entire menu before their first dish arrives.

This is the classic beginner mistake β€” one giant JavaScript bundle containing every page, every feature, shipped to a user who only wanted to see the login page. Code splitting fixes this: you break your app into small chunks, and the browser only downloads a chunk when it's actually needed.

// app/router.jsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import PageLoader from '../shared/components/PageLoader';

const OrdersPage = lazy(() => import('../features/orders/OrdersPage'));
const InvoicesPage = lazy(() => import('../features/invoices/InvoicesPage'));
const UsersPage = lazy(() => import('../features/users/UsersPage'));

function AppRouter() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/orders" element={<OrdersPage />} />
        <Route path="/invoices" element={<InvoicesPage />} />
        <Route path="/users" element={<UsersPage />} />
      </Routes>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So React.lazy + Suspense is basically "don't download this until someone actually visits it"?

πŸ‘¨β€πŸ¦³ Uncle: Exactly right. Vite (and Rollup underneath it) will automatically split each lazy() import into its own file at build time. A user visiting only the login page and their dashboard downloads only those chunks β€” not your entire invoicing module, not your admin settings, nothing they'll never touch that session.

The joke I tell every junior: shipping your whole app upfront is like moving into a new house and the moving truck insists on unloading furniture for rooms you haven't even built yet. Just bring what's needed for the room you're standing in.

There's a second layer here too β€” this is exactly the same idea as the feature-folder structure from Part 2. Because each feature already lives in its own self-contained folder, splitting it into its own lazy-loaded chunk is almost free β€” you didn't have to restructure anything. This is why architecture decisions made early pay compounding dividends later.


Part 6: Images β€” The Silent Bundle Killers

πŸ‘¦ Nephew: What about images? Product photos, avatars, banners β€” we have thousands.

πŸ‘¨β€πŸ¦³ Uncle: Never β€” and I mean never β€” put user-facing content images inside your JavaScript bundle by importing them directly into your app code as if they were components. A JavaScript bundle should contain logic, not media.

The rules:

  1. Serve images from a CDN or dedicated storage (S3 + CloudFront, Cloudinary, whatever your stack uses), never bundled with your app's JS. Your JS bundle should be tiny and cacheable independently from your images, which change far more often and are far heavier.
  2. Lazy-load images below the fold. The browser's native loading="lazy" attribute on <img> tags is enough for most cases β€” images only download when they're about to scroll into view.
  3. Use responsive, modern formats. Serve WebP/AVIF where supported, with a fallback, and serve appropriately sized images for the device β€” don't send a 4000px banner image to a phone screen that will display it at 400px.
  4. Reserve space before the image loads using width/height attributes or aspect-ratio CSS, so the layout doesn't jump when the image pops in β€” same Cumulative Layout Shift problem as skeletons.
<img
  src="https://cdn.example.com/products/shoe-400.webp"
  srcset="https://cdn.example.com/products/shoe-400.webp 400w,
          https://cdn.example.com/products/shoe-800.webp 800w"
  sizes="(max-width: 600px) 400px, 800px"
  loading="lazy"
  width="400" height="400"
  alt="Running shoe"
/>
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So the browser picks the right size itself?

πŸ‘¨β€πŸ¦³ Uncle: Exactly β€” you give it options, it picks based on screen size and pixel density. This one change alone β€” moving images out of the bundle and serving them properly β€” is usually the single biggest "why is our app slow to load" fix in real audits. People obsess over JS optimization while a 5MB hero banner sits there unloved.


Part 7: When Things Go Wrong β€” Error Boundaries

πŸ‘¦ Nephew: What happens if one component just... crashes? Like a bad API response breaks a chart component.

πŸ‘¨β€πŸ¦³ Uncle: Without protection, one broken component takes down your entire React app β€” a blank white screen, everything gone, because React unmounts the whole tree on an uncaught render error. This is called the white screen of death, and it's terrifying in production.

Error Boundaries are React's way of saying "if this specific part of the house catches fire, close the door to that room, don't burn the whole house down."

// shared/components/ErrorBoundary.jsx
import { Component } from 'react';

class ErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // log it β€” this is exactly the "log everything at boundaries" lesson
    logErrorToService(error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <DefaultErrorFallback />;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;
Enter fullscreen mode Exit fullscreen mode

And you wrap it around individual features, not just the whole app:

<ErrorBoundary fallback={<OrdersFailedToLoad />}>
  <OrdersPage />
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So if OrdersPage explodes, the rest of the app β€” the nav bar, the sidebar, other widgets β€” keeps working?

πŸ‘¨β€πŸ¦³ Uncle: Exactly. One broken room, doors closed, rest of the house is fine, and the resident (your user) can still get to the kitchen. This is non-negotiable for any serious product β€” wrap your major feature boundaries and any risky third-party-data-dependent components individually.


Part 8: Building for Change β€” Not Breaking When a Service Disappears

πŸ‘¦ Nephew: You said something about "what if tomorrow a service gets removed and it doesn't break the app." How is that even possible?

πŸ‘¨β€πŸ¦³ Uncle: This comes down to one architectural habit: never let your components talk directly to raw API shapes. Always go through an abstraction β€” in our case, that's exactly what the features/orders/api/ordersApi.js file is for. It's not just for caching; it's a contract.

Think about it this way: if your OrderRow component directly used order.cust_full_nm (some ugly backend field name) in twenty different places, and tomorrow the backend team renames that field, or swaps the whole payment provider that returns a completely different shape, you now have to hunt down twenty places to fix.

Instead, normalize data at the boundary:

// features/orders/api/ordersApi.js
getOrders: builder.query({
  query: () => '/',
  transformResponse: (response) => response.map(normalizeOrder),
  providesTags: ['Order'],
}),

// features/orders/api/normalizeOrder.js
function normalizeOrder(raw) {
  return {
    id: raw.order_id,
    customerName: raw.cust_full_nm,
    amount: raw.amt_total,
    status: raw.ord_status,
  };
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So every component downstream only ever sees customerName, never cust_full_nm?

πŸ‘¨β€πŸ¦³ Uncle: Correct. Now if the backend changes, or you swap providers entirely β€” say tomorrow you migrate from your own payment service to Stripe β€” you change one function, normalizeOrder, and every component in your app that consumes orders keeps working without a single change, because they were never coupled to the raw shape in the first place. This is the same principle as feature folders β€” isolate the thing likely to change, so change doesn't ripple outward.


Part 9: Preventing Unnecessary Re-renders β€” The "One Component Shouldn't Wake Up the Whole House" Problem

πŸ‘¦ Nephew: Okay now the thing that actually confuses me most β€” re-renders. I change one small piece of state and it feels like my whole page re-renders and gets sluggish.

πŸ‘¨β€πŸ¦³ Uncle: Let's actually understand why React re-renders before fixing it, because memorizing useMemo without understanding this is like taking painkillers without knowing what hurts.

How React actually decides to re-render

When a component's state or props change, React re-renders that component and every child component below it, by default β€” regardless of whether those children's own props actually changed. React then compares the new "virtual DOM" output against the previous one (this comparison is called reconciliation, or informally "diffing"), and only touches the real DOM where something actually differs.

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚   App        β”‚   <- state changes here
                 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                        β”‚  re-renders
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β–Ό             β–Ό             β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ Sidebar β”‚   β”‚ Header    β”‚  β”‚ Content  β”‚   <- ALL of these re-render too,
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜      even if their own props
                                       β”‚            never changed!
                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                          β–Ό            β–Ό            β–Ό
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚ OrderRowβ”‚  β”‚OrderRow β”‚  β”‚ OrderRow β”‚  <- and so on down
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     the whole tree
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: Wait β€” so if I click something in Sidebar, OrderRow re-renders too, even though it has nothing to do with the sidebar?

πŸ‘¨β€πŸ¦³ Uncle: By default, yes β€” if they share a state update higher up the tree that causes their common parent to re-render. This is fine for cheap components. It becomes a real problem when a child is expensive to re-render β€” a big chart, a huge table, something doing heavy calculation.

The fix β€” telling React "I promise nothing changed, skip me"

// React.memo β€” skip re-rendering if props are shallow-equal to last time
const OrderRow = React.memo(function OrderRow({ order, onMarkPaid }) {
  return (
    <tr>
      <td>{order.customerName}</td>
      <td>{order.amount}</td>
      <button onClick={onMarkPaid}>Mark Paid</button>
    </tr>
  );
});
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So React.memo is like putting a "do not disturb" sign on the door?

πŸ‘¨β€πŸ¦³ Uncle: Exactly that. But here's the trap β€” if the parent passes a brand-new function onMarkPaid on every render (which happens if you write onMarkPaid={() => updateOrder(order.id)} inline), that "new function" counts as a changed prop every single time, and your "do not disturb" sign becomes useless β€” React knocks anyway. That's what useCallback is for:

const handleMarkPaid = useCallback((id) => {
  updateOrder({ id, status: 'paid' });
}, [updateOrder]);
Enter fullscreen mode Exit fullscreen mode

And for expensive calculations (not components, actual computed values), useMemo is the equivalent β€” "don't recalculate this unless its actual inputs changed":

const sortedOrders = useMemo(
  () => [...orders].sort((a, b) => b.amount - a.amount),
  [orders]
);
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So memo protects components, useCallback protects functions from looking "new" every render, and useMemo protects expensive calculations?

πŸ‘¨β€πŸ¦³ Uncle: You just summarized it better than most senior devs do in interviews. One warning though β€” don't sprinkle useMemo/useCallback on everything reflexively. They have their own small cost of bookkeeping. Use them where you've actually noticed or measured a re-render problem β€” usually large lists, expensive charts, or deeply nested trees β€” not on a plain <div>{count}</div>.


Part 10: Understanding State Flow β€” The One-Way Street

πŸ‘¦ Nephew: I always hear "React is unidirectional data flow." What does that actually mean, visually?

πŸ‘¨β€πŸ¦³ Uncle: Picture a one-way street, not a roundabout. Data flows down through props, and when something needs to change, an action flows back up to whoever owns that state, never sideways, never magically mutated in place.

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚   Global Store       β”‚   (Redux / RTK Query cache)
   β”‚  (orders, users...)  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚  state flows DOWN as props
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚     OrdersPage        β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚     OrderRow          β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚  user clicks "Mark Paid"
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚   dispatch(action)    β”‚   action flows UP, never mutates state directly
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚   Store updates       β”‚  β†’  triggers re-render of anything subscribed
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So a component never just... changes the store's data directly?

πŸ‘¨β€πŸ¦³ Uncle: Never. It always asks β€” dispatches an action, or in RTK Query's case, fires a mutation β€” and the store is the only thing allowed to actually update itself, then it notifies whoever's listening. This predictability is the whole reason large React apps stay debuggable at scale β€” you can always answer "where did this value come from" by tracing one direction, down, and "what changed this value" by tracing one direction, up. Compare that to old-school two-way binding where any component could reach in and mutate shared state directly β€” with enough components doing that, you get a spaghetti bowl where nobody can tell what changed what.


Part 11: Local State vs Global State β€” Not Everything Belongs in Redux

πŸ‘¦ Nephew: So should everything go into the global Redux store?

πŸ‘¨β€πŸ¦³ Uncle: No β€” and this is a mistake almost every team makes once. Ask yourself: "does more than one, unrelated part of the app need this piece of state?" If a dropdown's open/closed state, or a form field's current text, is only relevant to that one component β€” keep it local, with useState, right there. Promoting everything to global state is like keeping your toothbrush in the living room "just in case" β€” technically accessible to everyone, practically just clutter and confusion about where things belong.

The decision tree Uncle actually uses:

  • Is it server data (came from an API)? β†’ RTK Query. Don't even consider plain useState + useEffect for this β€” you'll rebuild caching/loading/invalidation badly by hand.
  • Is it shared UI state used by multiple, unrelated components (like "is the sidebar collapsed," current theme, logged-in user)? β†’ Global store (Redux slice or Context, depending on how often it changes β€” more on that below).
  • Is it local to one component or its direct children (form input value, "is this modal open")? β†’ useState right there, no ceremony needed.

Part 12: Context API vs Redux β€” Choosing the Right Tool, Not the Trendy One

πŸ‘¦ Nephew: When do I use React Context instead of Redux for global stuff?

πŸ‘¨β€πŸ¦³ Uncle: Context is great for state that's global but changes rarely β€” theme (light/dark), current logged-in user, language/locale settings. The reason is subtle but important: every component that consumes a Context re-renders whenever that Context's value changes, with no built-in fine-grained subscription system like Redux has. For something that changes once in a blue moon (you flip dark mode maybe twice a session), that's totally fine.

For state that changes frequently and is read by many different, unrelated components β€” like our orders cache being updated constantly, filters being applied, pagination state β€” Redux (and RTK Query on top of it) gives you fine-grained subscriptions: a component only re-renders if the specific slice of state it selected actually changed, not on every single store update.

Uncle's simple rule: Context = rarely-changing, broadly-needed settings. Redux = frequently-changing, selectively-needed data.


Part 13: Dark Mode / Theming Done Properly

πŸ‘¦ Nephew: Speaking of theme β€” how do we architect dark mode so it doesn't touch every component's code?

πŸ‘¨β€πŸ¦³ Uncle: You never want dark mode logic living inside individual components β€” imagine writing isDark ? 'bg-black' : 'bg-white' in two hundred files. Instead, you push the decision down to CSS variables, and components stay blissfully unaware that dark mode even exists.

/* theme.css */
:root {
  --color-bg: #ffffff;
  --color-text: #111111;
  --color-card: #f5f5f5;
}

[data-theme='dark'] {
  --color-bg: #0f0f0f;
  --color-text: #f5f5f5;
  --color-card: #1c1c1c;
}
Enter fullscreen mode Exit fullscreen mode
// A tiny Context, ONLY for the toggle itself β€” this is exactly
// the "rarely changes, globally needed" case from Part 12.
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode
/* OrderRow.css β€” component has NO idea dark mode exists */
.order-row {
  background: var(--color-card);
  color: var(--color-text);
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So OrderRow never even checks if it's dark mode β€” the browser just resolves the variable based on the data-theme attribute on the root?

πŸ‘¨β€πŸ¦³ Uncle: Precisely. One toggle at the very top of the tree, CSS variables do the rest, and every single component in your 100-page app gets dark mode support for free, forever, without ever being touched again β€” even features you build a year from now automatically support it, because they just use the variables like everyone else.


Part 14: Handling Errors Gracefully in Forms and API Calls

πŸ‘¦ Nephew: What about regular errors β€” form validation, failed API calls that aren't full crashes?

πŸ‘¨β€πŸ¦³ Uncle: Error Boundaries (Part 7) catch render crashes β€” bugs. They are NOT for expected failures like "the server returned a validation error" or "the network is down." Those are normal, expected outcomes you should handle explicitly, gracefully, right where they happen.

function InvoiceForm() {
  const [createInvoice, { isLoading, error }] = useCreateInvoiceMutation();

  const handleSubmit = async (formData) => {
    try {
      await createInvoice(formData).unwrap();
      toast.success('Invoice created!');
    } catch (err) {
      // err.data.message might be "Customer email is invalid"
      // show it right next to the field, don't crash, don't blank-screen
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <FieldError message={error.data?.message ?? 'Something went wrong'} />}
      {/* form fields */}
      <button disabled={isLoading}>{isLoading ? 'Saving…' : 'Save Invoice'}</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: So the rule is: crashes go to Error Boundaries, expected failures get handled inline with clear messaging?

πŸ‘¨β€πŸ¦³ Uncle: Exactly β€” and never, ever show a user a raw technical error like TypeError: cannot read property 'id' of undefined or a raw stack trace. Translate every failure into a message a human can act on: "Your session expired, please log in again," not "401 Unauthorized." A user who sees a scary technical error loses trust in your entire product in that one instant.


Part 15: Putting It All Together β€” From Scratch to Production

πŸ‘¦ Nephew: Okay Uncle, walk me through the full journey β€” day one to launch day.

πŸ‘¨β€πŸ¦³ Uncle: Here's the whole arc, in order:

1. CHOOSE THE TOOL
   Vite (internal app) or Next.js (public/SEO-critical app)
        β”‚
        β–Ό
2. LAY OUT THE FOLDERS
   Feature-based structure, each feature self-contained,
   only exposed through its own index.js
        β”‚
        β–Ό
3. SET UP DATA LAYER FIRST
   RTK Query slice per feature β€” normalize data at the boundary
   so backend changes never leak into components
        β”‚
        β–Ό
4. BUILD FEATURES INSIDE ERROR BOUNDARIES
   Each major feature wrapped, so a broken chart doesn't
   take down the whole app
        β”‚
        β–Ό
5. LAZY-LOAD EACH ROUTE
   React.lazy + Suspense per page β€” nobody downloads
   code for pages they haven't visited
        β”‚
        β–Ό
6. ADD SKELETONS, NOT SPINNERS
   Every loading state gets a shaped placeholder matching
   the real content, to protect perceived speed
        β”‚
        β–Ό
7. MOVE MEDIA OUT OF THE BUNDLE
   CDN + lazy image loading + responsive sizes
        β”‚
        β–Ό
8. THEME AT THE CSS LAYER
   Dark mode via CSS variables, one toggle, zero component changes
        β”‚
        β–Ό
9. OPTIMIZE RE-RENDERS WHERE MEASURED
   React.memo / useMemo / useCallback only where profiling
   shows real, expensive re-renders β€” not everywhere
        β”‚
        β–Ό
10. BUILD FOR PRODUCTION
    Vite/Rollup bundles, splits chunks automatically along
    your lazy() boundaries, minifies, tree-shakes unused code
        β”‚
        β–Ό
11. SHIP, MEASURE, REPEAT
    Real user monitoring β€” page load time, error rates,
    Cumulative Layout Shift β€” feeds back into steps 4-9
Enter fullscreen mode Exit fullscreen mode

πŸ‘¦ Nephew: And this scales all the way to 100 pages because...?

πŸ‘¨β€πŸ¦³ Uncle: Because every single decision we made was designed around isolation β€” features that don't reach into each other, data shapes that don't leak backend details into components, chunks that load independently, errors that don't cascade, and a theme system that doesn't require touching every file. Page 101 gets built exactly the same way page 2 was β€” same pattern, same folder shape, same data layer approach. That repeatability, not cleverness, is what "scalable architecture" actually means. It's boring by design β€” and boring, predictable code is exactly what lets a team of ten people work on the same app without stepping on each other's feet.


Part 16: Uncle's Closing Words

πŸ‘¨β€πŸ¦³ Uncle: One last thing before you go build. Every single pattern I taught you today β€” feature folders, cache invalidation, lazy loading, error boundaries, normalization at the boundary β€” they all share one philosophy: isolate the part that's likely to change, so change doesn't ripple through the whole house. Backend renames a field? One normalizer function changes. Product kills a feature? One folder gets deleted. Designer wants dark mode? One CSS layer handles it. A junior on your team breaks something in Orders? The rest of the app survives.

πŸ‘¦ Nephew: So architecture isn't really about React at all, it's about... predicting what's going to change and building walls around it?

πŸ‘¨β€πŸ¦³ Uncle: Now you sound like an architect, not just a developer. Go build your house. And this time β€” leave room for the extra bedroom.


End of chat. Now go run npm create vite@latest like you mean it.

Top comments (0)