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
...
π¦ 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
π¦ 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;
π¦ 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' })}
/>
));
}
π¦ 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>
);
}
π¦ 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>
);
}
π¦ 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:
- 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.
-
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. - 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.
- 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"
/>
π¦ 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;
And you wrap it around individual features, not just the whole app:
<ErrorBoundary fallback={<OrdersFailedToLoad />}>
<OrdersPage />
</ErrorBoundary>
π¦ 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,
};
}
π¦ 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
π¦ 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>
);
});
π¦ 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]);
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]
);
π¦ 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
ββββββββββββββββββββββ
π¦ 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+useEffectfor 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")? β
useStateright 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;
}
// 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>
);
}
/* OrderRow.css β component has NO idea dark mode exists */
.order-row {
background: var(--color-card);
color: var(--color-text);
}
π¦ 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>
);
}
π¦ 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
π¦ 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)