Skip to main content

Command Palette

Search for a command to run...

How React's Render, Effects and Refs work under the hood

Never be confused again and write better React code.

Updated
10 min read
How React's Render, Effects and Refs work under the hood
T

Just a guy who loves to write code and watch anime.

Introduction

Understanding the entire render cycle of React and how it works with the browser isn't easy.

Even if you read the modern React documentation, it can be confusing considering all the visuals.

Let's keep it simple and direct. This is something I'm writing for myself to help me understand the full flow.

Let's start with this snippet to get thoughts rolling:

function ExploringReactRefs() {
  // Why does this ref start as null?
  // When does it get its actual value?
  const divRef = useRef<HTMLDivElement>(null);

  // This feels like it should work... but does it?
  // When exactly does this effect run?
  useEffect(() => {
    console.log("Effect:", divRef.current?.getBoundingClientRect());
  }, []);

  // What's different about this effect?
  // Why might we need this instead of useEffect?
  useLayoutEffect(() => {
    console.log("Layout Effect:", divRef.current?.getBoundingClientRect());
  }, []);

  // What's special about this callback ref approach?
  // When does this function actually get called?
  // See the second div below where handleRef is used.
  const handleRef = (node: HTMLDivElement | null) => {
    if (node) {
      console.log("Callback ref:", node.getBoundingClientRect());
    }
  };

  return (
    <div className="flex gap-4">
      {/* When can we actually access this element via divRef? */}
      <div ref={divRef}>Using useRef</div>

      {/* How is this different from useRef? */}
      <div ref={handleRef}>Using callback ref</div>
    </div>
  );
}

State update and renders

Whenever you a component's state is updated, React will re-render the component. Re-rendering a component will re-render all of its children (yes, you can optimize this, but that's not the point here).

And! Just to be clear, effects only run if their dependencies change. If it's an empty array, it will only run once when the component is mounted (created).

Let's just go over a snippet to be brutally clear about this:

function Component() {
  // 1. No dependency array - runs on EVERY render
  useEffect(() => {
    // Effect runs
    return () => {
      /* Cleanup runs before next effect */
    };
  }); // Missing dependency array

  // 2. Empty array - runs only on mount/unmount
  useEffect(() => {
    // Effect runs once
    return () => {
      /* Cleanup runs on unmount */
    };
  }, []);

  // 3. With dependencies - runs when deps change
  useEffect(() => {
    // Effect runs if count changed
    return () => {
      /* Cleanup runs before next effect if count changed */
    };
  }, [count]);

  // Same rules apply for useLayoutEffect
}

Mount means the component gets created.

Unmount means the component gets destroyed, or in simpler worlds, removed from the DOM. I used to believe in my younger days that this meant navigating away from the page. But this can also be the case if you're conditionally rendering a component.

Back to the snippet

There is quite a few things going on in this snippet.

When a component is rendered, it goes through two main phases:

  1. Render phase.

  2. Commit phase.

We're gonna break those down into simpler terms.

For now, understand that every single time a render happens, two phases are executed: Render and Commit.

Virtual DOM

Before we dive into Render phase, let's talk about the Virtual DOM.

A lot of people who lack understanding instantly rush towards saying "Virtual DOM is to make React faster". It's a bit funny, because that's not really the case. You've UI libraries today such as Solid.js that don't have a Virtual DOM and are faster than React. So that statement is very confusing and incorrect.

What I'm gonna be explaining is how things work at a high level.

In actuality, React uses Fiber architecture instead of a simple Virtual DOM. This let's React split work into chunks and prioritize it. This is still good for us to understand the basics.

What is Virtual DOM?

Virtual DOM is just JavaScript objects. It's a representation of the actual DOM.

// Virtual DOM is just JavaScript objects
const virtualElement = {
  type: "div",
  props: { className: "container" },
  children: [
    {
      type: "span",
      props: { children: "Hello" },
    },
  ],
};

// Real DOM is actual browser APIs
const realElement = document.createElement("div");
realElement.className = "container";

So here we noticed the first "cost" already. We're storing a representation of the DOM in memory. Now this isn't a big deal. Millions if not billions of websites are using React. The point is just to look at things from first principles and really understand what's happening instead of just saying things without any understanding.

Okay... But why does React need this?

There are two core philosophies I wanna mention here.

Different platforms

By having a virtual DOM, React isn't tied to the browser's DOM.

This means React can render to different platforms.

That's why React Native exists and works. Mobile apps are not using the browser's DOM hehe...

Just pseudo code for our enlightenment:

// React can render to different targets
function render(virtualElement) {
  switch (environment) {
    case "web":
      return renderToDOM(virtualElement);
    case "mobile":
      return renderToNative(virtualElement);
    case "server":
      return renderToString(virtualElement);
  }
}

Batching updates

As we discussed before, React re renders the entire component (including its children) whenever a state update happens.

This means when state updates happen, it could result in a lot of DOM changes in the end.

With Virtual DOM, React can batch these updates. It can figure out all the changes that it needs to do, and apply them in a single pass when the commit phase is executed.

// Without Virtual DOM
state.change1(); // DOM update
state.change2(); // DOM update
state.change3(); // DOM update

// With Virtual DOM
state.change1(); // Update virtual tree
state.change2(); // Update virtual tree
state.change3(); // Update virtual tree
// One single DOM update at the end!

Render phase

Let's finally talk about the render phase.

This is the first phase of the render cycle.

One thing that annoys me sometimes when learning is all the terminologies people try to use.

We can also call this the "first step" of going from state change to DOM change.

Let's look at some pseudo code:

// RENDER PHASE
function renderPhase(newState) {
  // 1. React creates/updates Virtual DOM by calling components
  function handleStateUpdate() {
    // Create new Virtual DOM tree
    const newVirtualDOM = {
      type: "div",
      props: { className: "app" },
      children: [
        {
          type: "span",
          props: { children: newState },
        },
      ],
    };

    // 2. Reconciliation (Diffing)
    // React compares new Virtual DOM with previous one
    // Figures out what needs to change in real DOM
    const changes = diff(previousVirtualDOM, newVirtualDOM);
    // Results in a list of required DOM operations
    // [{type: 'UPDATE', path: 'span/textContent', value: newState}]
  }
}
  1. With new state, React creates a new Virtual DOM tree.

  2. React uses this new Virtual DOM tree to figure out what changes need to be made to the actual DOM.

  3. It does so by comparing the new Virtual DOM tree with the previous one.

Now React knows exactly the changes that need to be made and we don't need to update the full DOM every time a state update happens.

Now, you might think updating the full DOM would be very expensive. The real answer he is that "it could be". It depends on what you're building. It can also be good enough. So we can't say for sure that it would be VERY expensive (this is just me thinking very thoroughly, carefully and from first principles here).

Commit phase

Ok. Now we know what changes we need to do.

The commit phase is often summarized as "React updates the DOM". But it's a bit deeper than that.

PS! If you're not familiar with the event loop, I recommend reading up on it before continuing. I've a free advanced JS book. Chapters 12, 13 and 14 are relevant if you wanna learn more about the event loop. MDN and Youtube are also good resources.

Let's look at some pseudo code:

// 1. React's Commit Phase (Synchronous JavaScript)
// This runs on the main thread
function commitToDOM() {
 // React calls DOM APIs
 // Each call gets added to the call stack
 mutateDOM() {
   document.createElement()
   element.setAttribute()
   element.appendChild()
   // ...
 }

 // remember useLayoutEffect?
 // Now we'll run all the layout effects
 // this is synchronous
 // the code in here gets added to the call stack too
 runLayoutEffects()

 // Queue useEffect for later
 queueMicrotask(() => {
   runEffects()
 })
}

// commitToDOM() is done - time for browser to work

// 2. Browser's Work
// - Calculate Layout
// - Paint
// - Composite

// 3. Microtask Queue
// Now useEffect runs

Now, how browsers work is out of scope for this post. But that is super interesting. It's on my list of things to learn in 2025. I so badly wanna explore it deeply lmao. I've done some research on it where I dug into hidden classes and stuff. Let's go over those points quickly, then get back to the topic:

  • Calculating layout: Browser calculates exact positions and sizes.

  • Paint: Browser converts layout results into visual pixels.

  • Composite: Browser combines layers into final screen image.


The first thing: Because we now know the updates we need to make, we run synchronous JavaScript code (mutateDOM()).

If we just look at this small snippet to get a feel for the event loop:

function mutateDOM() {
  document.createElement();
  element.setAttribute();
  element.appendChild();
}

mutateDOM();

Every call gets added to the call stack. Then the browser clears it from top to bottom.

1. element.appendChild()
2. element.setAttribute()
3. document.createElement()
4. mutateDOM()

A stack is a LIFO (Last In First Out) data structure.


When we run the layout effects, we're running synchronous JavaScript code. The function call and the ones it contains get added to the call stack. Now, if you've been following along closely, you understand that every time layout effects' dependencies change, they will run again. This MEANS more synchronous code to go through before the browser can do its thing (which is why React recommends to be careful with useLayoutEffect).

I'm trying to go through everything in detail and relate to practical points along the way for us to really understand this.


We then run the normal effects. These are queued up with queueMicrotask() in our example. HOWEVER, in actuality, React uses its own scheduling system. But I think it helps to view it as a microtask queue to sort of understand the basics.

When the browser does its thing, it's gonna first clear the entire call stack before it runs anything from the microtask queue. Then it runs the microtask queue.

Now we've covered everything except the refs.

Refs

Now, React 19 is out.

I don't plan on diving into the details of that, I'll do it in an upcoming blog post.

Aye. Let's focus on the refs from the original snippet.

const divRef = useRef<HTMLDivElement>(null);

This ref is created during the render phase. It starts as null because the DOM element doesn't exist during the first render. It gets its actual value after React commits the changes to the DOM. But you can't know exactly when this happens just by using useRef alone.

That's why you always need to check if the ref is null before you use it.

if (divRef.current) {
  console.log(divRef.current.getBoundingClientRect());
}

What happens when you use a callback ref?

const handleRef = (node: HTMLDivElement | null) => {
  if (node) {
    console.log("Callback ref:", node.getBoundingClientRect());
  }
};

Called immediately when the element is attached to the DOM. You can be 100% sure that the callback ref will run at the right time. It is null when the element is removed in case you need to clean up. It runs before useLayoutEffect. It's best for immediate DOM measurements or setup.

I need to get dimensions of an element... when is the best time to do this?

Let's say you need to know how to position a tooltip:

function Tooltip({ text, targetRef }) {
  const tooltipRef = useRef(null);

  // Wrong: Might cause flicker
  // Why?
  // Because this happens after the DOM is painted
  // You will tooltip in its original position
  // Then it flickers when this runs
  useEffect(() => {
    const targetRect = targetRef.current.getBoundingClientRect();
    tooltipRef.current.style.top = `${targetRect.bottom}px`;
  }, []);

  // Better: No flicker
  // Why?
  // Because this happens before the DOM is painted
  // You will see the tooltip in its final position
  useLayoutEffect(() => {
    const targetRect = targetRef.current.getBoundingClientRect();
    tooltipRef.current.style.top = `${targetRect.bottom}px`;
  }, []);

  // Best: Most direct
  // Why?
  // Because this happens immediately after the DOM is attached (layout effect happens AFTER the DOM is attached)
  const handleRef = (node) => {
    if (node) {
      const targetRect = targetRef.current.getBoundingClientRect();
      node.style.top = `${targetRect.bottom}px`;
    }
  };

  return <div ref={handleRef}>{text}</div>;
}

When do cleanup functions run?

After a render, right BEFORE React runs the effect (useEffect or useLayoutEffect, only if dependencies changed), it runs the cleanup functions with the previous values. Then it runs the new effect with the new values. Or of course if the component unmounts.

K

The breakdown of the render phase vs commit phase distinction is solid — one thing worth adding is that React 18's concurrent features mean the render phase can now be interrupted and restarted, which changes how you reason about side effects during rendering. The batching section is especially relevant since automatic batching in React 18 now applies inside setTimeout and promises too, not just event handlers.

G

ChatGPT brought me here when I asked for the best resources to revise React's render cycle, and it didn't disappoint, thanks.

Can I suggest a clarification?

"Every call gets added to the call stack. Then the browser clears it from top to bottom."

This seems a bit misleading, as if all the function calls shown in the snippet are first added to the call stack and only then are they all executed. But each of the DOM API calls would be added to the call stack, executed, and then popped off, before the next one would be added.

Not react-related, but it threw me for a minute.

M

Just love the way you explained! I'm totally satisfied, thanks

C

This post is amazing! Congrats for make all this complicated things much easier to understand even for people that works long time with React.

R
RiyaSree1y ago

Really enjoyed this post! You explained everything so clearly. Do you think one method works better than another in some situations?

G

Love the way you write about such a technical stuff. Keep up the great job!

2
T

Thank you :D