React Component Lifecycle

November 28, 2024 · Updated on November 29, 2024

Today, I was deep into a React project that relied on user scroll and dynamic component height changes to display a scroll indicator. Sounds straightforward, right? Yeah... no. Things spiraled into chaos pretty quickly, and I found myself brushing up on the React component lifecycle like it was my first day coding.

The super-simplified version of React’s lifecycle is:

  • Mount: Your component makes its grand debut on the page.
  • Update: Props or state change, and React works its magic to reflect that.
  • Unmount: The component leaves the stage.

Behind the scenes, React uses the virtual DOM to track changes and only updates the actual DOM when necessary. Neat, right? But understanding when and how to hook into these lifecycle phases is where things get tricky.

Enter useEffect and useLayoutEffect

I was already familiar with useEffect for tracking changes or handling mount/unmount behavior. Then there’s useLayoutEffect, which executes its logic after React has worked on the virtual DOM but before the browser paints anything on the screen. It’s perfect for layout calculations that need to happen ASAP.

But here’s where things got interesting: What happens when you use these hooks without a dependency array—or with an empty one?

Synchronous vs. Asynchronous: The Key Difference

One thing I had to clarify for myself was the key difference in timing between useEffect and useLayoutEffect:

  • useEffect is asynchronous: It runs after the browser has painted the DOM. This means the user can see updates on the screen before the code inside useEffect executes. It’s perfect for non-blocking operations like API calls or logging.
  • useLayoutEffect is synchronous: It blocks the browser from painting until its code runs. This makes it ideal for tasks like measuring DOM elements or making layout adjustments because you can ensure everything is in place before the user sees it.

Here’s a mental model:

  • useLayoutEffect feels like the backstage crew adjusting the props before the curtains rise.
  • useEffect is the post-show cleanup crew tidying up after the audience has already seen the stage.

When working with components that depend on precise measurements or UI tweaks, this distinction is huge.

The Experiment

Take this component as an example:

const Component = () => {
	const [count, setCount] = useState(0);
 
	useEffect(() => {
		console.log("UseEffect with empty dependency array");
	}, []);
 
	useLayoutEffect(() => {
		console.log("UseLayoutEffect with empty dependency array");
	}, []);
 
	useEffect(() => {
		console.log("UseEffect with no dependency");
	});
 
	useLayoutEffect(() => {
		console.log("UseLayoutEffect with no dependency");
	});
 
	useEffect(() => {
		console.log("UseEffect with count: " + count);
	}, [count]);
 
	useLayoutEffect(() => {
		console.log("UseLayoutEffect with count: " + count);
	}, [count]);
 
	console.log("React component rendered");
 
	return (
		<div>
			<button
				onClick={() => {
					console.log("Increase Button Clicked");
					setCount((c) => c + 1);
				}}
			>
				Increase
			</button>
			<div>count: {count}</div>
		</div>
	);
};
 

Now, pause for a moment. What would you expect to see in the console when this component renders and you click the button?

Console Logs:

What’s Actually Happening?

Before you check, try to visualize it in your head. Done? Okay, here’s the breakdown:

  1. Initial Render:
    • The component renders, logging "React component rendered".
    • Both useEffect and useLayoutEffect hooks with empty dependency arrays fire once, logging their respective messages.
  2. No Dependencies:
    • useEffect and useLayoutEffect with no dependency array fire every time the component renders (initial and updates).
  3. With [count] Dependency:
    • The useEffect and useLayoutEffect hooks with [count] fire only when count changes.
  4. Button Click:
    • Clicking the button triggers setCount, causing a state update. This:
      • Re-renders the component, logging "Increase Button Clicked" and "React component rendered".
      • Triggers the hooks as described above.

tl;dr

  • useEffect with an empty array runs once after the component mounts.
  • useEffect without a dependency array runs after every render.
  • useLayoutEffect behaves similarly but fires earlier in the update cycle.
  • Including dependencies (e.g., [count]) ensures the hook runs only when those values change.

If you’re as obsessed with debugging React behavior as I am, you’ll find this useful next time you’re in lifecycle chaos!