DEV Community

Cover image for React, Explained Directly โ€” Episode 1: The Fundamentals
surajrkhonde
surajrkhonde

Posted on

React, Explained Directly โ€” Episode 1: The Fundamentals

๐Ÿ—๏ธ Part 1: Why React Was Built

๐Ÿ‘ฆ Nephew: Before React, how did people actually build websites?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Directly. Every time something needed to change on a page โ€” a price updates, a list item gets added โ€” the code reached straight into the real page (the DOM, which stands for Document Object Model โ€” it's the browser's live, in-memory representation of everything on the page) and changed it by hand, using plain JavaScript.

The problem: touching the real DOM is expensive. Every time you change something in it, the browser has to recalculate layout and repaint parts of the screen. If you're making dozens of small changes, doing each one directly means dozens of expensive recalculations, one after another.

๐Ÿ‘ฆ Nephew: Didn't jQuery fix that?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: jQuery made it easier to write โ€” shorter code, simpler syntax for finding and changing elements. But it didn't fix the underlying problem. You were still deciding, by hand, exactly what to change and when. As an app grows, manually tracking every possible change and writing code for each one becomes unmanageable.


๐Ÿ“ˆ Part 2: The Actual Problem That Led to React

๐Ÿ‘ฆ Nephew: What specifically pushed teams toward inventing something like React?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Scale, and constantly-changing UI. Think about a site like Facebook: a "like" count changes, a friend's status changes, a notification badge updates, comments load in โ€” dozens of small UI updates per second, across millions of users simultaneously.

If every one of those updates means directly touching the real DOM, performance falls apart fast. So the core idea became: don't touch the real DOM directly for every small change. Instead, keep an in-memory copy, compute what actually needs to change, and only then touch the real DOM โ€” and only for the parts that actually changed.

That in-memory copy is the Virtual DOM.


๐ŸŒณ Part 3: The Virtual DOM

๐Ÿ‘ฆ Nephew: How does the Virtual DOM actually work, step by step?

๐Ÿ‘จโ€๐Ÿฆณ Uncle:

  1. โœ๏ธ You write your component code โ€” this describes what the UI should look like.
  2. ๐Ÿงฉ React builds a lightweight JavaScript object representing that UI. This is the Virtual DOM. It is not the real DOM โ€” it's just a plain data structure in memory, and building/comparing it is much cheaper than touching the real DOM.
  3. โšก Something happens โ€” a state change, a prop change, a user event.
  4. ๐Ÿงฉ React builds a new Virtual DOM tree, reflecting the updated UI.
  5. ๐Ÿ” React compares the old Virtual DOM tree to the new one. This comparison process is called reconciliation, and the specific step of finding what's different is called diffing.
  6. โœ… React applies only the differences to the real DOM โ€” not the whole page, just the specific nodes that actually changed.
Old Virtual DOM  โ”€โ”€โ”
                    โ”œโ”€โ”€โ–บ  diff  โ”€โ”€โ–บ  minimal set of real DOM updates
New Virtual DOM  โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

This is the entire performance trick: computation happens cheaply in memory first; only the minimal necessary write happens on the real, expensive DOM.


๐Ÿงต Part 4: React Fiber and Concurrent Rendering

๐Ÿ‘ฆ Nephew: What is "Fiber" and why does it matter?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Fiber is the internal engine React uses to actually perform rendering work. Before Fiber, React's rendering process was synchronous and blocking โ€” once React started rendering an update, it had to finish the entire thing before it could do anything else, including responding to new user input.

That created a real problem: if a large re-render was in progress (say, rendering a long list) and the user typed in a search box at that exact moment, the typing could visibly lag, because React couldn't interrupt the render in progress to handle the more urgent keystroke.

๐Ÿ‘ฆ Nephew: How does Fiber fix that?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Fiber restructured rendering into small, interruptible units of work instead of one uninterruptible block. This enables Concurrent Rendering: React can pause a lower-priority render (like an off-screen update or a background data render), immediately handle a higher-priority update (like user input), and then resume the paused work afterward.

Old React:  [ render block, cannot be interrupted ] โ†’ then handle input
New React:  [ render chunk ] โ†’ urgent input arrives โ†’ [ handle input immediately ] โ†’ [ resume render ]
Enter fullscreen mode Exit fullscreen mode

This scheduling is automatic โ€” React's internal scheduler decides priority; you don't manually configure this for basic use, although APIs like useTransition and useDeferredValue let you hint priority explicitly for specific updates (covered in Episode 2).


๐Ÿ”ค Part 5: JSX

๐Ÿ‘ฆ Nephew: Why does React let us write HTML-like syntax directly inside JavaScript?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: JSX is not something browsers understand natively. It's a syntax extension that gets compiled ("transpiled") into plain JavaScript function calls before it ever runs in a browser. The tool that does this is Babel (or, in modern toolchains, a similarly-purposed compiler bundled into tools like Vite or Next.js).

Before React 17, JSX compiled into calls to React.createElement:

// You write:
<h1>Welcome!</h1>

// It compiles to:
React.createElement('h1', null, 'Welcome!')
Enter fullscreen mode Exit fullscreen mode

Since React 17, the compiled output no longer requires importing React in every file that uses JSX โ€” the new JSX transform handles it differently under the hood, without changing how you write JSX day to day.

Rules JSX enforces, because it's structurally closer to XML than HTML:

  • ๐Ÿ”’ Every tag must be explicitly closed (<img />, not <img>)
  • ๐Ÿท๏ธ class becomes className (because class is a reserved word in JavaScript)
  • ๐Ÿซ Multi-word HTML attributes become camelCase (onclick โ†’ onClick, tabindex โ†’ tabIndex)

๐Ÿงฑ Part 6: Components

๐Ÿ‘ฆ Nephew: What is a component, precisely?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: A function (or, in older code, a class) that returns JSX describing part of the UI. Components are meant to be composed โ€” you build small components and combine them into larger ones, forming a tree structure.

        App
       /   \
   Header   Main
    /  \      |
  Logo  Nav  ProductList
Enter fullscreen mode Exit fullscreen mode

๐Ÿ‘ฆ Nephew: Can the same component be used multiple times?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Yes, and each usage is an independent instance โ€” its own state, its own lifecycle, completely isolated from other instances of the same component elsewhere in the tree.

Function components (the modern standard) are plain functions:

function Welcome() {
  return <h1>Hello</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Class components (the older style, still valid, still used in legacy codebases) extend React.Component:

class Welcome extends React.Component {
  render() {
    return <h1>Hello</h1>;
  }
}
Enter fullscreen mode Exit fullscreen mode

There was an even older pattern, React.createClass, deprecated since React 15.5 โ€” you won't encounter it in modern code, but it's worth knowing it existed if you ever read old tutorials or legacy code.


๐ŸŽ Part 7: Props vs State

๐Ÿ‘ฆ Nephew: What's the actual difference?

๐Ÿ‘จโ€๐Ÿฆณ Uncle:

  • ๐Ÿ“ฆ Props โ€” data passed into a component from its parent. Read-only from the receiving component's perspective โ€” a component must never mutate its own props directly.
  • ๐Ÿ—’๏ธ State โ€” data a component manages internally, that it can change itself, typically in response to user interaction or other events.
Parent
  โ”‚  (props passed down)
  โ–ผ
Child (uses props, manages its own state internally)
  โ”‚  (calls a function passed down as a prop, to notify parent of something)
  โ–ฒ
Parent (receives the notification, updates its own state if needed)
Enter fullscreen mode Exit fullscreen mode

๐Ÿ‘ฆ Nephew: Why is data flow one-directional like that?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Because two-way binding (where a child can directly mutate a parent's data) makes it hard to trace why something changed โ€” was it user interaction in the child, or a change originating from the parent? One-directional flow keeps updates traceable: data flows down through props, and any need to communicate upward happens through callback functions the parent explicitly provides โ€” not by the child reaching up and mutating parent state directly.

This is also why mutating props directly is disallowed โ€” React relies on the parent being the single source of truth for that data; if a child could silently change it, React's re-render logic and the parent's own state would fall out of sync.


๐Ÿ›๏ธ Part 8: Class Component Internals

๐Ÿ‘ฆ Nephew: What's actually happening with this, bind, and constructor in class components?

๐Ÿ‘จโ€๐Ÿฆณ Uncle:

The constructor runs once, when the component instance is created:

constructor(props) {
  super(props);
  this.state = { count: 0 };
  this.handleClick = this.handleClick.bind(this);
}
Enter fullscreen mode Exit fullscreen mode

super(props) is required if you want to access this.props inside the constructor โ€” it passes props up to React.Component's own constructor first.

โš ๏ธ Why binding matters: In JavaScript, a function's this depends on how the function is called, not where it was defined. If you pass this.handleClick as a callback (say, to an onClick handler) without binding it, this inside handleClick will be undefined when it actually runs โ€” because it's being called as a plain function reference, disconnected from the component instance.

// Without binding โ€” this is undefined inside handleClick when it's actually called
<button onClick={this.handleClick}>Click</button>

// Binding fixes it:
this.handleClick = this.handleClick.bind(this);
Enter fullscreen mode Exit fullscreen mode

โฑ๏ธ Why setState doesn't update immediately:

this.setState({ count: this.state.count + 1 });
console.log(this.state.count); // may still log the OLD value
Enter fullscreen mode Exit fullscreen mode

React batches state updates for performance โ€” it doesn't necessarily apply and re-render on every single setState call the instant it's called. If your next value depends on the previous one, use the function form to get the guaranteed-current value:

this.setState((prevState) => ({ count: prevState.count + 1 }));
Enter fullscreen mode Exit fullscreen mode

โณ Part 9: Component Lifecycle (Class Components)

๐Ÿ‘ฆ Nephew: What is "lifecycle," specifically?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: The sequence of methods React calls automatically as a component is created, updated, and removed.

๐ŸŒฑ Mounting (first render):

constructor โ†’ render โ†’ componentDidMount
Enter fullscreen mode Exit fullscreen mode

componentDidMount runs once, right after the component first appears in the DOM. This is the correct place to fetch initial data or set up subscriptions, because the DOM node now actually exists.

๐Ÿ”„ Updating (state or props change):

shouldComponentUpdate โ†’ render โ†’ componentDidUpdate
Enter fullscreen mode Exit fullscreen mode

shouldComponentUpdate lets you explicitly return false to skip re-rendering entirely, if you know a particular change doesn't affect this component's output โ€” a manual performance optimization.

๐Ÿšช Unmounting (component removed):

componentWillUnmount
Enter fullscreen mode Exit fullscreen mode

This runs right before the component is removed. Anything the component started that would otherwise keep running โ€” timers, subscriptions, event listeners โ€” must be cleaned up here.

๐Ÿ‘ฆ Nephew: What happens if that cleanup is skipped?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: A memory leak. Something the component started โ€” a setInterval, a subscription, an event listener โ€” keeps running even though the component that created it no longer exists. This wastes resources and, worse, can cause errors if that leftover code tries to update state on a component that's already gone.


๐Ÿช Part 10: Hooks โ€” The Full Toolbox

๐Ÿ‘ฆ Nephew: Why do hooks exist at all? Why not just always use classes?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Two real reasons, and both matter.

Reason 1 โ€” plain JavaScript functions don't retain data between calls. Every time a function runs, it starts fresh. Before hooks, function components had no way to hold onto data (like a counter, or a toggle) across re-renders โ€” this is why they were once called "stateless" components. Hooks give function components the ability to hold and update data across renders, matching what class components already did with this.state.

Reason 2 โ€” sharing logic between class components was genuinely painful. If two unrelated class components both needed the same piece of stateful behavior (say, "track window width" or "subscribe to a websocket"), your only real options were awkward patterns like Higher-Order Components or render props โ€” both of which wrap your component in extra layers, making the component tree harder to read and debug. Hooks let you extract that exact same logic into a plain function (a custom hook, covered below) and reuse it directly, with no wrapping, no extra tree depth, no confusion about where a prop actually came from.

๐Ÿ‘ฆ Nephew: So every hook is really just solving one of those two problems โ€” "give me memory" or "let me reuse logic"?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Exactly that. Keep that lens on as we go through each one โ€” for every hook, ask "is this managing memory across renders, or letting me reuse/organize logic?" It'll make the whole list click into place instead of feeling like a random API surface to memorize.

๐Ÿ—’๏ธ useState โ€” holding a value across renders

const [count, setCount] = useState(0);
Enter fullscreen mode Exit fullscreen mode

Why we need it: a function component, by itself, forgets everything between renders. useState is React's way of saying "keep this specific value alive, tied to this specific component instance, across every re-render, until the component unmounts."

You never mutate count directly. Calling setCount(newValue) tells React to schedule a re-render with the new value. count itself is treated as immutable within a single render โ€” it only "updates" because the next render gets a fresh value.

// If the next value depends on the current one, use the function form โ€”
// it always receives the guaranteed-latest value, avoiding stale-value bugs:
setCount(prev => prev + 1);
Enter fullscreen mode Exit fullscreen mode

๐ŸŽฌ useEffect โ€” reaching outside the component

useEffect(() => {
  // runs after the component mounts
}, []); // empty dependency array = run once, on mount only

useEffect(() => {
  // runs after mount, and again every time `count` changes
}, [count]);

useEffect(() => {
  const id = setInterval(() => {}, 1000);
  return () => clearInterval(id); // cleanup โ€” runs before unmount, or before the effect re-runs
}, []);
Enter fullscreen mode Exit fullscreen mode

Why we need it: rendering a component should be a pure calculation โ€” given the same props/state, it should produce the same output, with no side effects like fetching data, subscribing to something, or manually touching the DOM. But real apps need side effects. useEffect is the designated, controlled place for them โ€” it runs after React has already updated the screen, so your side effect never blocks or interferes with the actual rendering calculation itself.

๐Ÿ“Œ useRef โ€” a box that survives renders without causing them

๐Ÿ‘ฆ Nephew: This is the one I actually want explained properly โ€” why do we need useRef at all, when we already have useState?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Good, because this is exactly where people get confused. useState and useRef both let a value survive across renders. The difference is the one thing that actually matters:

Changing a useState value triggers a re-render. Changing a useRef value does not.

const countRef = useRef(0);

function handleClick() {
  countRef.current = countRef.current + 1;
  console.log(countRef.current); // updates immediately, correctly
  // but the component does NOT re-render because of this line
}
Enter fullscreen mode Exit fullscreen mode

Why does that matter in practice? Because not everything you want to remember should cause the screen to redraw. Concrete real reasons to reach for useRef:

  1. Holding a timer or interval ID, so you can clear it later โ€” the ID itself is never displayed on screen, so there's no reason for changing it to trigger a re-render.
   const timerRef = useRef(null);
   function start() { timerRef.current = setInterval(tick, 1000); }
   function stop() { clearInterval(timerRef.current); }
Enter fullscreen mode Exit fullscreen mode
  1. Directly accessing a real DOM node โ€” focusing an input, measuring an element's size, scrolling to a position. useRef is the bridge between React's declarative world and these one-off, imperative DOM actions:
   const inputRef = useRef(null);
   useEffect(() => { inputRef.current.focus(); }, []);
   return <input ref={inputRef} />;
Enter fullscreen mode Exit fullscreen mode
  1. Remembering a previous value without forcing extra renders โ€” e.g., tracking the previous prop value to compare against the current one, purely for internal logic, with no need to display it:
   const prevCountRef = useRef();
   useEffect(() => { prevCountRef.current = count; }, [count]);
   // prevCountRef.current now holds "count from the last render" during this render
Enter fullscreen mode Exit fullscreen mode
  1. Avoiding a stale closure inside a long-lived callback (see Episode 2's "stale closures" section) โ€” because ref.current is always read fresh at the moment you access it, unlike a variable captured inside a closure created earlier.

The rule of thumb Uncle actually uses: "Does the user need to see this value change on screen?" If yes โ†’ useState. If no, and it's just bookkeeping the component needs internally โ†’ useRef. Using useState for something that never needs to be displayed just causes unnecessary re-renders; using useRef for something that should be displayed means the screen silently goes stale, because React never knows to re-render when it changes.

๐Ÿงฎ useMemo โ€” caching an expensive calculation

const total = useMemo(() => expensiveCalculation(items), [items]);
Enter fullscreen mode Exit fullscreen mode

Why we need it: without it, an expensive calculation inside a component body re-runs on every single render, even if the actual inputs to that calculation haven't changed. useMemo stores the result and only recalculates when something in the dependency array actually changes โ€” trading a bit of memory for skipping repeated, unnecessary work.

๐Ÿ”— useCallback โ€” caching a function reference

const handleClick = useCallback(() => { doSomething(id); }, [id]);
Enter fullscreen mode Exit fullscreen mode

Why we need it: in JavaScript, a new function you define inside a component body is a brand new object on every render, even if its logic never changed. This matters specifically when you pass that function down to a child wrapped in React.memo โ€” the child will see "a new prop" every time and re-render anyway, defeating the whole point of memoizing it. useCallback keeps the same function reference across renders, as long as its dependencies haven't changed, so a memoized child can correctly recognize "nothing actually changed here."

๐Ÿ›ก๏ธ React.memo โ€” skipping a whole component's re-render

const Row = React.memo(function Row({ data }) {
  return <div>{data.name}</div>;
});
Enter fullscreen mode Exit fullscreen mode

Why we need it: by default, when a parent re-renders, every child it renders re-renders too โ€” regardless of whether that child's own props changed. React.memo tells React to compare this component's props to last time, and skip re-rendering entirely if they're the same. This is the function-component equivalent of React.PureComponent for class components, which does the identical prop comparison automatically.

๐ŸŒ useContext โ€” reading shared data without prop drilling

const ThemeContext = createContext('light');

function DeepChild() {
  const theme = useContext(ThemeContext); // reads the nearest matching Provider's value
  return <p>Current theme: {theme}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Why we need it: without it, if a deeply nested component needs a piece of data (like the current logged-in user, or a theme setting), every single intermediate component in between has to accept and forward that value as a prop, even if it never actually uses it itself. This is called prop drilling, and it becomes a real maintenance headache as an app grows. useContext lets any component, at any depth, read a value directly from the nearest Provider above it โ€” no manual relaying required.

๐Ÿ–ผ๏ธ useLayoutEffect โ€” like useEffect, but timed differently

useLayoutEffect(() => {
  // runs synchronously, AFTER React updates the DOM but BEFORE the
  // browser actually paints that update on screen
}, []);
Enter fullscreen mode Exit fullscreen mode

Why we need it: useEffect runs after the browser has already painted the update โ€” which is fine for most side effects, but occasionally causes a visible flicker, if your effect needs to measure or adjust the DOM (like repositioning a tooltip based on its actual rendered size) before the user sees anything. useLayoutEffect runs at that earlier point, blocking the paint until it finishes, specifically to avoid that flicker. It's used rarely, and only when timing like this actually matters โ€” reaching for it by default instead of useEffect can hurt performance, since it blocks painting until it completes.

๐ŸŽฎ useImperativeHandle โ€” controlling exactly what a parent can do to a child via ref

const FancyInput = React.forwardRef(function FancyInput(props, ref) {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(), // only exposing `focus`, nothing else
  }));

  return <input ref={inputRef} />;
});
Enter fullscreen mode Exit fullscreen mode

Why we need it: normally, passing a ref down to a custom component and expecting to control its internals directly doesn't work cleanly โ€” refs naturally attach to a single DOM node, not a whole component's internal API. useImperativeHandle, combined with React.forwardRef, lets a component deliberately expose a specific, limited set of actions to whatever holds its ref (here, just focus), instead of exposing its entire internal DOM node and every possible action on it. This keeps the component's internals properly encapsulated, even while still allowing a parent to trigger specific behavior imperatively when genuinely needed.

๐Ÿ†” useId โ€” generating stable, unique IDs

function LabeledInput() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Name</label>
      <input id={id} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why we need it: <label>/<input> pairs (and other accessibility-related attributes) require a matching, unique id to be correctly associated. Hand-writing a fixed string ID breaks the moment the same component is rendered more than once on the same page โ€” you'd get duplicate IDs. useId generates a unique ID per component instance, safely, including in server-rendered apps where client and server need to agree on the exact same ID without one side accidentally guessing differently.


๐Ÿ” Part 11: Custom Hooks โ€” Reusing Logic, Not Just Values

๐Ÿ‘ฆ Nephew: What if several components need the exact same piece of stateful logic โ€” not just the same type of hook, but the same actual behavior?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: You extract it into a custom hook โ€” a regular JavaScript function, by convention named starting with use, that internally calls other hooks and returns whatever the calling component needs.

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

function Component() {
  const width = useWindowWidth();
  return <p>Window width: {width}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Why we need it: without this, every component that needs "the current window width" would have to copy-paste the same useState + useEffect + event listener logic individually. A custom hook lets you write that logic exactly once. There's no new mechanism happening here โ€” a custom hook is just a reusable function that happens to call other hooks internally. Any component that calls it gets its own independent state, exactly as if it had written that logic itself, inline.


โš™๏ธ Part 12: useReducer โ€” When useState Isn't Enough

๐Ÿ‘ฆ Nephew: When does useState stop being the right tool?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: When state updates get complex โ€” multiple related fields, or update logic that depends on what kind of action occurred, not just a plain new value. useReducer centralizes that update logic into a single function:

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    default: return state;
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: 'increment' });
Enter fullscreen mode Exit fullscreen mode

Why we need it: instead of directly setting new state from many different places in your component, components dispatch an "action" describing what happened, and one single reducer function decides how state should change in response. This keeps update logic in one predictable, testable place instead of scattered across multiple setState calls that might conflict with each other. This is the same core pattern Redux uses, just scoped to one component instead of an entire application.


๐Ÿšง Part 13: Error Boundaries

๐Ÿ‘ฆ Nephew: What happens if a component throws an error during rendering?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: By default, an uncaught error during rendering unmounts the entire component tree โ€” the whole app can go blank. An Error Boundary is a class component that catches errors thrown by its children during rendering and displays a fallback UI instead of crashing the entire app.

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

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

  render() {
    if (this.state.hasError) return <p>Something went wrong.</p>;
    return this.props.children;
  }
}

<ErrorBoundary>
  <RiskyComponent />
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Note: Error Boundaries only catch errors during rendering, in lifecycle methods, and in constructors of components below them โ€” they do not catch errors inside event handlers, async code, or server-side rendering. Those need regular try/catch.


๐Ÿ“ฆ Part 14: Suspense and Lazy Loading

๐Ÿ‘ฆ Nephew: What's React.lazy actually doing?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: It defers loading a component's code until it's actually needed, instead of bundling it into the initial JavaScript download. This is called code splitting.

const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

<Suspense fallback={<p>Loading...</p>}>
  <HeavyComponent />
</Suspense>
Enter fullscreen mode Exit fullscreen mode

Suspense provides a fallback UI to display while the lazily-loaded component's code is still being fetched. Without code splitting, users download the JavaScript for every component up front, even ones they may never actually visit on that session โ€” which slows down the initial page load unnecessarily.

Without code splitting: download everything upfront โ†’ slower initial load
With code splitting:    download only what's needed now โ†’ faster initial load,
                         remaining chunks fetched on demand
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฉ Part 15: Fragments and Portals

๐Ÿ‘ฆ Nephew: Why do I sometimes need <></>?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: A component must return a single root element. If you want to return multiple sibling elements without wrapping them in an unnecessary extra <div> (which would add a meaningless node to the actual DOM), you use a Fragment:

return (
  <>
    <ChildOne />
    <ChildTwo />
  </>
);
Enter fullscreen mode Exit fullscreen mode

Portals let you render a component's output into a different part of the actual DOM tree than where it's logically defined in your component tree โ€” commonly used for modals, tooltips, and overlays that need to visually escape a parent's CSS constraints (like overflow: hidden), while still behaving as a normal part of the React component tree for props, context, and event bubbling:

ReactDOM.createPortal(<Modal />, document.getElementById('modal-root'));
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”ฌ Part 16: StrictMode

๐Ÿ‘ฆ Nephew: What does <React.StrictMode> actually do?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: In development only (it has no effect in production builds), it intentionally double-invokes certain functions โ€” like component render functions and some lifecycle/effect logic โ€” specifically to help surface bugs where code incorrectly relies on side effects only happening once, or where cleanup isn't properly implemented. It's a development-time diagnostic tool, not a runtime feature that affects your live app.


๐ŸงŠ Part 17: Stale Closures

๐Ÿ‘ฆ Nephew: Why does my useEffect sometimes use an old value even though the real value has clearly changed?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: This is a stale closure. When a function is created, it "closes over" the variables available at that moment โ€” it captures their value at creation time, not a live reference that automatically updates later.

useEffect(() => {
  const interval = setInterval(() => {
    console.log(count); // always logs whatever `count` was when this effect ran
  }, 1000);
  return () => clearInterval(interval);
}, []); // empty array โ€” this effect (and its captured `count`) never refreshes
Enter fullscreen mode Exit fullscreen mode

Because the dependency array is empty, this effect runs only once, and the function inside permanently references the count value from that single run. Fix it by including count in the dependency array, so the effect re-runs (recreating the interval with a fresh closure) whenever count actually changes:

useEffect(() => {
  const interval = setInterval(() => {
    console.log(count); // fresh value every time the effect re-runs
  }, 1000);
  return () => clearInterval(interval);
}, [count]);
Enter fullscreen mode Exit fullscreen mode

๐Ÿ–Š๏ธ Part 18: Controlled vs. Uncontrolled Components

๐Ÿ‘ฆ Nephew: What's the actual difference in form handling?

๐Ÿ‘จโ€๐Ÿฆณ Uncle:

Controlled โ€” the input's value is driven entirely by React state. Every keystroke updates state via onChange, and the input's displayed value comes from that state:

const [value, setValue] = useState('');
<input value={value} onChange={(e) => setValue(e.target.value)} />
Enter fullscreen mode Exit fullscreen mode

React state is the single source of truth at every moment โ€” useful when you need to validate input as the user types, or conditionally disable a submit button based on live input.

Uncontrolled โ€” the DOM itself manages the input's current value; React doesn't track it on every keystroke. You access the value only when needed, via a ref (yes โ€” this is the same useRef from Part 10, used for exactly the "direct DOM access" reason discussed there):

const inputRef = useRef();
<input ref={inputRef} defaultValue="" />
// later: inputRef.current.value
Enter fullscreen mode Exit fullscreen mode

Uncontrolled is simpler when you don't need to react to every keystroke โ€” just the final value at submission time.


๐Ÿงพ Part 19: Conditional Rendering and Lists

๐Ÿ‘ฆ Nephew: How does conditional display actually work in JSX?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: It's just JavaScript expressions embedded in JSX:

{isLoggedIn ? <Dashboard /> : <LoginForm />}
{isAdmin && <AdminPanel />}
Enter fullscreen mode Exit fullscreen mode

For rendering a list of elements, use .map(), and every generated element needs a unique key prop:

{items.map(item => <Item key={item.id} data={item} />)}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ‘ฆ Nephew: Why does key matter so much?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: React uses key to match elements between renders during reconciliation. Without stable, unique keys, React may misidentify which item is which when the list changes (items added, removed, or reordered), leading to incorrect updates, lost input state, or unnecessary re-renders of items that didn't actually change. Using array index as a key works only if the list order never changes โ€” otherwise it can cause real bugs.


๐ŸŒ Part 20: How the Page Actually Loads (Browser + React Together)

๐Ÿ‘ฆ Nephew: Where does React actually fit into the page load process?

๐Ÿ‘จโ€๐Ÿฆณ Uncle:

1. Browser requests the page โ†’ receives HTML
2. Browser parses HTML, applies CSS โ†’ initial paint (if any content exists yet)
3. Browser downloads JavaScript (your React bundle)
4. React executes, builds its Virtual DOM tree
5. React commits that tree to the real DOM
6. (if server-rendered) React "hydrates" โ€” attaches event handlers and
   internal state to DOM nodes that were already present in the HTML,
   without rebuilding them from scratch
Enter fullscreen mode Exit fullscreen mode

๐Ÿ–ฅ๏ธ Client-Side Rendering (CSR): the browser initially receives a mostly empty HTML file (often just a single <div id="root"></div>). Nothing meaningful appears until the JavaScript downloads and executes. Simple to set up, but slower initial content display, and weaker for SEO, since crawlers that don't execute JavaScript see an empty page.

๐Ÿ–จ๏ธ Server-Side Rendering (SSR): the server renders the initial HTML output on each request and sends that to the browser. The user sees meaningful content immediately, before any JavaScript has even loaded. Once the JavaScript does load, React "hydrates" that existing HTML โ€” attaching interactivity without re-building the DOM from scratch.

๐Ÿ“„ Static Site Generation (SSG): the same idea as SSR, but the HTML is generated once, at build time, not per-request โ€” and then served as a static file, which is faster to serve since there's no per-request rendering cost on the server.

๐Ÿ‘ฆ Nephew: Whatever we do, what does the browser actually end up with?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Ultimately, only plain HTML, CSS, and JavaScript. React, JSX, hooks, and components are development-time abstractions that get compiled away โ€” the browser has no concept of any of them; it just sees final DOM nodes, styles, and event handlers.


๐Ÿ”Ž Part 21: Why Next.js Exists

๐Ÿ‘ฆ Nephew: If React already exists, why does Next.js matter?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Plain React (via tools like Create React App or Vite) typically gives you Client-Side Rendering by default. As covered above, that means search engine crawlers, or anyone visiting before JavaScript finishes loading, initially see an essentially empty page.

Next.js is a framework built on top of React that adds, among other things, built-in Server-Side Rendering and Static Site Generation, file-based routing, and automatic code splitting per page. The core benefit relevant to SEO: content is present in the initial HTML response, so crawlers and users see meaningful content immediately, without depending on JavaScript execution first.


๐Ÿ“ก Part 22: State Management Beyond a Single Component โ€” Context, Redux, React Query

๐Ÿ‘ฆ Nephew: When does passing props down stop being practical?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: When data needs to reach a component several levels deep, and every intermediate component in between has to accept and forward that prop even though it doesn't use it itself. This is prop drilling โ€” and it's exactly the problem useContext (Part 10) solves for a single value. Now let's look at the bigger tools built on similar ideas.

Context โ€” covered in Part 10, best suited for values that don't change extremely frequently (theme, authenticated user, locale), because every component consuming a Context re-renders when that Context's value changes.

Redux is a more structured, centralized state management library. All application state lives in a single store. Components don't mutate that state directly โ€” they dispatch actions describing what happened, and pure functions called reducers compute the new state based on the current state and the action โ€” the exact same pattern as useReducer from Part 12, just scaled up to the whole app instead of one component:

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 };
    default: return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

This centralization makes state changes traceable and predictable, particularly valuable in large applications with complex, interdependent state โ€” and it comes with tooling (Redux DevTools) for inspecting and even replaying state changes.

React Query (also known as TanStack Query) is specifically for managing data that comes from a server (API responses) โ€” not general app state. It handles caching, background refetching, deduplication of simultaneous requests, and marking data as "stale" so it automatically refreshes when needed:

const { data, isLoading } = useQuery(['todos'], fetchTodos);
Enter fullscreen mode Exit fullscreen mode

Redux and React Query solve different problems โ€” Redux manages client-side application state, React Query manages the lifecycle of server-fetched data. Many real apps use both together, each for what it's actually good at.

๐Ÿ‘ฆ Nephew: When one piece of state changes, does the entire app re-render?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: No โ€” only the component whose state or subscribed Context actually changed, plus its child components (which re-render by default, though React.memo can prevent that when their own props haven't changed). Keeping state as localized as possible, and splitting Context providers by concern rather than using one giant Context for everything, both reduce unnecessary re-renders.


๐Ÿงญ Part 23: Routing

๐Ÿ‘ฆ Nephew: How do multiple "pages" work in a single-page React app?

๐Ÿ‘จโ€๐Ÿฆณ Uncle: A routing library (commonly React Router) maps URL paths to specific components, without triggering a full browser page reload. The browser's URL changes, but it's still the same loaded JavaScript application โ€” React just swaps which component tree is currently rendered.

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/products" element={<Products />} />
  <Route path="/profile" element={<Profile />} />
</Routes>
Enter fullscreen mode Exit fullscreen mode

Nested routes let a parent route render shared layout (navigation, sidebar) while only a specific inner section changes based on the URL:

<Route path="/products" element={<ProductsLayout />}>
  <Route path="phones" element={<Phones />} />
  <Route path="laptops" element={<Laptops />} />
</Route>
Enter fullscreen mode Exit fullscreen mode

ProductsLayout renders its own shared UI plus an <Outlet />, which is where React Router inserts whichever nested route currently matches.

Protected routes check a condition (typically authentication status) before rendering the intended component, redirecting elsewhere if the condition isn't met:

function ProtectedRoute({ children }) {
  const isAuthenticated = useAuth();
  if (!isAuthenticated) return <Navigate to="/login" />;
  return children;
}

<Route path="/dashboard" element={
  <ProtectedRoute><Dashboard /></ProtectedRoute>
} />
Enter fullscreen mode Exit fullscreen mode

๐Ÿ Part 24: Full Flow, Start to Finish

๐Ÿ‘จโ€๐Ÿฆณ Uncle: Putting it all together, in order:

  1. ๐Ÿ”จ Build tooling compiles JSX and bundles JavaScript (via Babel and a bundler like Webpack or Vite). If using Next.js, some pages may be pre-rendered to static HTML at this stage.
  2. ๐Ÿ“จ A request arrives. Depending on your rendering strategy, the server either sends pre-rendered HTML (SSR/SSG) or a minimal shell (CSR).
  3. ๐ŸŽจ The browser parses and paints whatever HTML/CSS it has immediately.
  4. โšก JavaScript loads and executes. If HTML was server-rendered, React hydrates it โ€” attaching interactivity to existing DOM nodes rather than rebuilding them.
  5. ๐Ÿ–ฑ๏ธ The app becomes interactive. User actions trigger state changes.
  6. ๐Ÿ” Each state change triggers a new Virtual DOM tree, which gets diffed against the previous one, and only the actual differences are applied to the real DOM.
  7. ๐Ÿงญ Routing swaps which component tree is rendered as the URL changes, without a full page reload.
  8. ๐Ÿงน When a component unmounts, its cleanup logic runs (clearing timers, unsubscribing listeners) to avoid leaving anything running unnecessarily.

๐Ÿ‘ฆ Nephew: So it's really just: minimize direct DOM changes, batch and prioritize updates intelligently, and give developers a component-based way to describe UI instead of manually scripting every change.

๐Ÿ‘จโ€๐Ÿฆณ Uncle: That's the whole thing. Every hook, every lifecycle method, every optimization technique exists to solve one of those three problems. Once you can trace any given feature back to which of those three problems it solves, you actually understand React โ€” not just its API surface.


๐Ÿ“‹ Quick Reference

Concept What it actually does
Virtual DOM In-memory representation of the UI, diffed before touching the real DOM
Reconciliation / Diffing Comparing old and new Virtual DOM trees to find minimal necessary changes
Fiber React's internal rendering engine, enabling interruptible, prioritized rendering
Concurrent Rendering Letting urgent updates preempt lower-priority ones
JSX Syntax compiled by Babel into plain JavaScript function calls
Props Read-only data passed from parent to child
State Data a component owns and can update itself
๐Ÿ—’๏ธ useState Persists a value across re-renders; changing it triggers a re-render
๐ŸŽฌ useEffect Runs side effects tied to mount, update, or unmount, after the DOM updates
๐Ÿ“Œ useRef Persists a value across renders without triggering a re-render; also used for direct DOM access
๐Ÿงฎ useMemo Caches an expensive calculated value between renders
๐Ÿ”— useCallback Caches a function reference between renders
๐Ÿ›ก๏ธ React.memo Skips re-rendering a component if its props haven't changed
๐ŸŒ useContext Reads a shared value from the nearest Provider, avoiding prop drilling
๐Ÿ–ผ๏ธ useLayoutEffect Like useEffect, but runs before the browser paints โ€” avoids visual flicker
๐ŸŽฎ useImperativeHandle Lets a component expose a limited, specific set of actions via ref
๐Ÿ†” useId Generates a unique, stable ID per component instance
๐Ÿ” Custom Hook A reusable function that internally uses other hooks
โš™๏ธ useReducer Centralizes complex state update logic into one function
๐Ÿšง Error Boundary Catches rendering errors in child components, shows a fallback UI
๐Ÿ“ฆ React.lazy / Suspense Defers loading a component's code until needed, with a loading fallback
๐Ÿงฉ Fragment Groups elements without adding an extra DOM node
๐Ÿงฒ Portal Renders output into a different DOM location while staying in the React tree
๐Ÿ”ฌ StrictMode Development-only tool that double-invokes code to surface bugs
๐ŸงŠ Stale closure A function that captured an outdated variable value from when it was created
๐Ÿ–Š๏ธ Controlled input Input value driven by React state
๐Ÿ–Š๏ธ Uncontrolled input Input value managed by the DOM itself, read via a ref when needed
key prop Lets React correctly match list items between renders
Hydration Attaching interactivity to already-existing, server-rendered HTML
SSR Rendering HTML on the server, per request
SSG Rendering HTML once, at build time
Redux Centralized state store, updated via dispatched actions and reducers
React Query Manages fetching, caching, and refreshing server data
Router Maps URL paths to components without a full page reload
Nested Routes Shared layout with an inner section that changes by URL
Protected Routes Conditionally renders a route based on auth status

For every row above, be able to state the actual problem it solves โ€” that's the difference between knowing React's API and actually understanding React.

Top comments (0)