Deep Dive 02 - Advanced Hooks Internals

Deep Dive 02 - Advanced Hooks Internals

Hey everyone! Welcome back to Namaste React Deep Dives!

You already know HOW to use hooks like useState, useEffect, useRef. But do you know HOW they actually work under the hood?

I am super excited because this is the stuff that senior engineers talk about in interviews! Understanding the WHY behind hooks will make you a much better React developer. Trust me!

What we will cover:

  • How React stores hooks internally (the linked list)
  • Why Rules of Hooks exist (finally makes sense!)
  • useRef — it's just a box, not magic
  • useState is secretly built on useReducer
  • useContext — how context travels through your app
  • useMemo and useCallback — how memoization works
  • useLayoutEffect vs useEffect — the timing difference
  • useId — generating stable unique IDs
  • Building useState from scratch (mind = blown!)
  • Interview Questions

1. How React Stores Hooks — The Linked List

This confuses a lot of people! Let me clear it up with a simple example.

When you write a component like this:

function Counter() {
    const [count, setCount] = useState(0);       // Hook 1
    const [name, setName]   = useState("React"); // Hook 2
    const myRef             = useRef(null);       // Hook 3
}

Think of it like this:

React creates a NUMBERED LIST of boxes for your component:

  Box 1  →  { value: 0,       type: "useState" }   ← count
  Box 2  →  { value: "React", type: "useState" }   ← name
  Box 3  →  { value: null,    type: "useRef"   }   ← myRef

Every time your component re-renders, React goes through
this list IN ORDER:
  1st hook call → reads Box 1
  2nd hook call → reads Box 2
  3rd hook call → reads Box 3

In simple words:

React tracks your hooks by POSITION, not by name!

This is exactly why the Rules of Hooks exist! If you put a hook inside an if block:

// ❌ WRONG — Never do this!
function Counter() {
    const [count, setCount] = useState(0);    // Hook 1 ✅

    if (someCondition) {
        const [name, setName] = useState(""); // Hook 2 — might be SKIPPED!
    }

    const myRef = useRef(null);               // Reads Box 2 now instead of Box 3! ❌
}

// Render 1: condition = true  → boxes: [count, name, ref]   ✅
// Render 2: condition = false → boxes: [count, ref]
//           React gives the "ref" box to the "name" variable!
//           Everything is now WRONG and broken! 💀

That's why React says: "Call hooks at the top level, always, in the same order every render!"


2. useRef — It's Just a Box

A lot of people think useRef is complex magic. It's not!

In simple words:

useRef = a box that holds a value, and changing the value does NOT re-render your component!

const myRef = useRef(0);

// myRef is literally just this object:
{ current: 0 }

// That's it! A plain JavaScript object with a "current" property.

What makes it special?

React creates this box once and gives you the SAME box on every re-render. You can change what's inside the box, but React doesn't care — it won't redraw your component!

Think of useRef like a POST-IT NOTE stuck to your component:
===============================================================
  useState:   Change the value → React REDRAWS the screen 🎨
  useRef:     Change the value → React does NOTHING (no redraw!) 📝
===============================================================

Use Case 1 — Accessing a DOM element:

function TextInput() {
    const inputRef = useRef(null);

    const handleClick = () => {
        inputRef.current.focus(); // directly access the DOM element!
    };

    return (
        <>
            <input ref={inputRef} />
            {/* React sets inputRef.current = the actual <input> DOM node */}
            <button onClick={handleClick}>Focus the input</button>
        </>
    );
}

Use Case 2 — Storing a timer ID without causing re-renders:

function Timer() {
    const [count, setCount] = useState(0);
    const timerRef = useRef(null); // store the timer ID here

    const start = () => {
        timerRef.current = setInterval(() => {
            setCount(c => c + 1);
        }, 1000);
    };

    const stop = () => {
        clearInterval(timerRef.current); // use the stored timer ID
    };

    return (
        <div>
            {count}
            <button onClick={start}>Start</button>
            <button onClick={stop}>Stop</button>
        </div>
    );
}

// If we used useState for the timer ID, every update would
// trigger a re-render — which we don't want!

3. useState is Built On useReducer

This is one of the most surprising things about React internals!

In simple words:

useState is NOT a separate hook. It is useReducer with a simple built-in reducer!

// Inside React's source code, useState looks roughly like this:

function useState(initialValue) {
    return useReducer(
        (currentState, newValue) => {
            // setCount(prev => prev + 1)  → newValue is a function, call it
            if (typeof newValue === "function") return newValue(currentState);

            // setCount(5)  → newValue is a value, use it directly
            return newValue;
        },
        initialValue
    );
}

// That's it! useState is useReducer with a tiny built-in reducer!

Think of it like this:

useReducer  =  a full professional kitchen 🍳  (you write the recipe)
useState    =  a microwave 📦  (built using the kitchen, easy to use!)

When should you use each?

useState:    simple single values — count, name, isOpen, loading
useReducer:  complex state — multiple values that change together,
             when the next state depends on the current state in complex ways

A quick useReducer example:

// With useState (three separate variables):
const [count, setCount]     = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError]     = useState(null);

// With useReducer (one object, much cleaner):
const [state, dispatch] = useReducer((state, action) => {
    switch(action.type) {
        case "INCREMENT":   return { ...state, count: state.count + 1 };
        case "SET_LOADING": return { ...state, loading: action.value };
        case "SET_ERROR":   return { ...state, error: action.value };
        default:            return state;
    }
}, { count: 0, loading: false, error: null });

// Usage:
dispatch({ type: "INCREMENT" });
dispatch({ type: "SET_LOADING", value: true });

4. useContext — How Context Travels Through Your App

Context is confusing! Here is the best analogy:

Think of it like a Wi-Fi signal:

Without Context (prop drilling):
==================================
Router (parent component)
  → Cable → Room 1 (passes data as prop)
    → Cable → Room 2 (passes data as prop)
      → Cable → Room 3 (finally uses the data!)

This is "prop drilling" — passing data through every level.
It's messy and annoying!


With Context (Wi-Fi):
=======================
Router (Provider) → 📶 BROADCASTS the signal

Any device in the house (any component in the tree)
can CONNECT directly — no cables needed!

How to set it up:

// Step 1: Create the context (one time)
const ThemeContext = React.createContext("light");

// Step 2: Wrap your app in the Provider (the Wi-Fi router)
function App() {
    const [theme, setTheme] = useState("dark");

    return (
        <ThemeContext.Provider value={theme}>
            <Header />
            <Body />     {/* any child, at ANY depth, can now read theme! */}
            <Footer />
        </ThemeContext.Provider>
    );
}

// Step 3: Any component connects with useContext (any device on Wi-Fi)
function Header() {
    const theme = useContext(ThemeContext); // reads "dark" — no props needed!
    return <header className={theme}>...</header>;
}

function DeepNestedComponent() {
    const theme = useContext(ThemeContext); // still works! No prop drilling!
    return <div className={theme}>...</div>;
}

Important thing to know!

When the Provider's value changes, every component using that context re-renders. So split your contexts for things that change at different rates!

// ❌ One big context — everything re-renders when anything changes:
const AppContext = createContext({ user, theme, cartCount });

// ✅ Split contexts — components only re-render for what they actually use:
const UserContext  = createContext(null);    // changes rarely
const ThemeContext = createContext("light"); // changes rarely
const CartContext  = createContext(0);       // changes often

// A Header that only uses ThemeContext won't re-render when cart changes!

5. useMemo and useCallback — How Memoization Works

These two hooks confuse a lot of people. Let me clear it up!

First — what is memoization? Think of it like a calculator with memory:

Normal calculator:
  1000 × 1000 → calculates → 1000000
  1000 × 1000 → calculates AGAIN → 1000000  (repeats work every time!)

Calculator WITH memory (useMemo):
  1000 × 1000 → calculates → 1000000 → REMEMBERS the answer
  1000 × 1000 → "I know this!" → 1000000  (skips the work!)

useMemo = "remember the result, only recalculate if the inputs change"

useMemo in practice:

// ❌ Without useMemo — filter runs on EVERY render:
function RestaurantList({ restaurants, searchText }) {
    const filtered = restaurants.filter(r =>
        r.name.toLowerCase().includes(searchText)
    );
    // Runs even if only an unrelated state changed!
    return <div>{filtered.map(r => <Card key={r.id} data={r} />)}</div>;
}

// ✅ With useMemo — filter only runs when searchText changes:
function RestaurantList({ restaurants, searchText }) {
    const filtered = useMemo(() =>
        restaurants.filter(r =>
            r.name.toLowerCase().includes(searchText)
        ),
    [searchText]); // ← only recalculate when searchText changes!

    return <div>{filtered.map(r => <Card key={r.id} data={r} />)}</div>;
}

Now — useCallback. In simple words:

useCallback = useMemo but for FUNCTIONS instead of values!

// useMemo    → remembers a computed VALUE
// useCallback → remembers a FUNCTION reference


// When does useCallback help?
// When you pass a function to a memoized child component:

const Button = React.memo(({ onClick }) => {
    console.log("Button rendered!");
    return <button onClick={onClick}>Click</button>;
});

function Parent() {
    const [count, setCount] = useState(0);

    // ❌ Without useCallback:
    // New function created on every render
    // React.memo thinks onClick "changed" → Button re-renders every time!
    const handleClick = () => setCount(c => c + 1);

    // ✅ With useCallback:
    // Same function reference every render
    // React.memo sees onClick is same → Button skips re-render! 🚀
    const handleClick = useCallback(() => setCount(c => c + 1), []);

    return (
        <>
            <p>Count: {count}</p>
            <Button onClick={handleClick} />
        </>
    );
}

Important gotcha — objects and arrays in dependency array:

// ❌ This NEVER memoizes — object is new on every render!
const result = useMemo(() => compute(filters), [filters]);
// { category: "pizza" } on render 1  ← new object
// { category: "pizza" } on render 2  ← another new object
// React compares with Object.is() — two objects are never the same!

// ✅ Use the specific primitive values instead:
const result = useMemo(() => compute(filters), [filters.category]);
// "pizza" === "pizza"  ← this comparison works correctly!

6. useLayoutEffect vs useEffect — The Timing Difference

This is a very popular interview question! Think of it like painting a room:

useEffect (normal flow):
========================
1. React updates the DOM      (the room is ready)
2. Browser PAINTS the screen  (user sees the room)
3. useEffect runs             (you rearrange furniture)

Problem: User sees the room, THEN sees furniture move. Looks flickery!


useLayoutEffect:
========================
1. React updates the DOM         (the room is ready)
2. useLayoutEffect runs          (you rearrange furniture FIRST)
3. Browser PAINTS the screen     (user sees final result)

User sees the FINAL result. No flicker! ✅
// Timeline:
useEffect:
  React renders → DOM updated → Browser PAINTS → useEffect runs
                                 ↑ user sees this

useLayoutEffect:
  React renders → DOM updated → useLayoutEffect runs → Browser PAINTS
                                                         ↑ user sees this


// WHEN to use useLayoutEffect:
// Only when you need to read or change the DOM BEFORE the user sees it
// to avoid a visible flicker.

// Example: tooltip that must know its own size to position correctly:
function Tooltip({ anchor, text }) {
    const tooltipRef = useRef();
    const [pos, setPos] = useState({ top: 0, left: 0 });

    useLayoutEffect(() => {
        // Measure the tooltip BEFORE it paints — no flicker!
        const { height } = tooltipRef.current.getBoundingClientRect();
        const anchorPos  = anchor.current.getBoundingClientRect();
        setPos({ top: anchorPos.top - height - 8, left: anchorPos.left });
    });

    return <div ref={tooltipRef} style={pos}>{text}</div>;
}

// RULE: Always start with useEffect.
// Only switch to useLayoutEffect if you notice a visual flicker.

7. useId — Stable Unique IDs

React 18 added useId and it solves a real, common problem!

The problem: Form labels need an id to link with their input. If you reuse the same component twice, you get duplicate IDs — which breaks accessibility!

// ❌ Hardcoded ID — breaks when component is reused:
function EmailInput() {
    return (
        <>
            <label htmlFor="email">Email</label>
            <input id="email" />
        </>
    );
}

// If you use <EmailInput /> twice → two elements with id="email"! 💀
// Screen readers get confused. Tests break.


// ❌ Math.random() — breaks server-side rendering:
const id = Math.random().toString();
// Server generates "0.abc123", client generates "0.xyz789"
// They don't match → React hydration error!


// ✅ useId — the correct solution:
function EmailInput() {
    const id = useId(); // generates ":r0:", ":r1:", etc.

    return (
        <>
            <label htmlFor={id}>Email</label>
            <input id={id} />
        </>
    );
}

// Now every <EmailInput /> gets its OWN unique ID!
// Same ID on server and client — no hydration errors!

In simple words:

useId = "give me a unique ID that won't clash, works with SSR, and stays stable across re-renders"


8. Building useState From Scratch!

This is where it all comes together. Let's build our OWN version of useState! This will show you EXACTLY why the Rules of Hooks exist.

// Step 1: Simplest version — only works for ONE state variable:

let _state; // stored outside the component so it survives re-renders

function myUseState(initialValue) {
    if (_state === undefined) {
        _state = initialValue; // set initial value only once
    }

    function setState(newValue) {
        _state = newValue;
        rerender(); // manually trigger a re-render
    }

    return [_state, setState];
}

// Problem: What if we have TWO useState calls in one component?
// Both read and write the SAME _state variable! 💀
// Step 2: Support multiple hooks using an ARRAY:

let _states    = [];  // stores ALL hook values
let _hookIndex = 0;   // tracks WHICH hook we're currently on

function myUseState(initialValue) {
    const index = _hookIndex; // capture the index for THIS hook
    _hookIndex++;              // move to next slot for the next hook

    if (_states[index] === undefined) {
        _states[index] = initialValue;
    }

    function setState(newValue) {
        _states[index] = newValue; // only update THIS hook's slot
        _hookIndex = 0;            // reset before re-render!
        rerender();
    }

    return [_states[index], setState];
}

function rerender() {
    _hookIndex = 0;   // ← MUST reset before each render!
    MyComponent();    // re-run the component function
}

// Now multiple hooks work correctly!
function MyComponent() {
    const [count, setCount] = myUseState(0);       // reads _states[0]
    const [name, setName]   = myUseState("React"); // reads _states[1]
}

Now you can see exactly WHY the rule exists:

// ❌ Hook inside an if block:
function MyComponent() {
    const [count, setCount] = myUseState(0);  // always reads _states[0]

    if (someCondition) {
        const [name] = myUseState("React");   // reads _states[1] — sometimes!
    }

    const [isOpen] = myUseState(false);       // reads _states[1] or _states[2]??
}

// Render 1: condition true  → _states = [0, "React", false]  ✅
//
// Render 2: condition false → "name" hook is SKIPPED
//           _hookIndex never reaches 2...
//           isOpen hook reads _states[1] = "React" instead of false! ❌
//           Completely wrong values! App breaks silently!

MIND BLOWN, right?!

The Rules of Hooks are not arbitrary! They exist because React uses an ordered list to track your hooks, and that list must stay the same on every render!


Interview Questions

Trust me, interviewers LOVE asking about hooks internals!

Q: Why can't you call hooks inside if statements or loops?

"React tracks hooks by their order — not by name. Internally, React stores hook values in an ordered list. On every render, the 1st hook call reads slot 1, the 2nd reads slot 2, and so on. If a hook is inside an if block, it might be skipped on some renders, shifting all subsequent hooks to the wrong slots. React would give the wrong value to the wrong variable. That's why hooks must always be called in the same order at the top level."

Q: What is the difference between useEffect and useLayoutEffect?

"Both run after React updates the DOM. The difference is timing relative to the browser paint. useEffect runs AFTER the browser paints — the user sees the update first, then the effect runs. useLayoutEffect runs BEFORE the browser paints — React holds off until the effect finishes. This matters when you need to measure and reposition DOM elements before the user sees them, to prevent flickering. I always start with useEffect and only switch to useLayoutEffect if I see a flicker."

Q: Is useState built on useReducer?

"Yes! In React's source code, useState is literally implemented as useReducer with a simple built-in reducer. That reducer checks if you passed a function — like setCount(prev => prev + 1) — and calls it with current state, or uses the value directly if you passed a value like setCount(5). That's the entire difference between the two."

Q: What is useRef and when would you use it over useState?

"useRef returns a plain object { current: value }. React creates this once and gives you the same object on every render. The key difference from useState is that changing .current does NOT trigger a re-render. I use it for three things: accessing DOM elements directly like calling .focus() on an input, storing mutable values that should persist across renders without causing re-renders like a timer ID, and avoiding stale closure issues in effects."

Q: When would you use useReducer instead of useState?

"I use useReducer when state logic gets complex — when I have multiple related state variables that change together, or when I have many different types of updates. For example, a form with loading, error, and data states. With useState I need three separate variables. With useReducer I manage them as one object with clear action types. It also makes the code easier to test since the reducer is a pure function."

Key Points to Remember

HookIn Simple WordsKey Rule
useStateA box that re-renders the component on changeBuilt on useReducer internally
useReduceruseState but with custom update logicUse when state logic is complex or has many action types
useRefA box that does NOT re-render on changeSame { current } object returned on every render
useMemoCache an expensive calculated VALUEOnly recalculates when dependency values change
useCallbackCache a FUNCTION (useMemo for functions)Helps memoized child components skip re-renders
useContextRead a value broadcast from a Provider above youAll consumers re-render when Provider value changes
useLayoutEffectLike useEffect but runs BEFORE browser paintOnly for fixing visual flicker — prefer useEffect
useIdGet a unique stable ID for this component instanceSafe for SSR, no duplicate IDs across instances

What's Next?

In Deep Dive 03, we explore React Server Components — the biggest architectural shift in React's history! Server-rendered, zero-bundle, async components — the future of React!

Make sure you understand today's concepts before moving on — these are the building blocks that make everything else click!

Keep coding, keep learning! See you in the next one!