β»οΈ The Component Lifecycle
Every React component lives a small life: it's born (mounted onto the screen), it grows and changes (updates as props and state shift), and eventually it disappears (unmounts). Knowing exactly when each phase happens β and which Hook fires when β is what turns "it mostly works" into components you can reason about with confidence.
Week 4 · Day 2 (Tuesday: State and Lifecycle) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Name the three lifecycle phases: mounting, updating, and unmounting
- Trace the render β commit β effect cycle React runs on each phase
- Map each old class lifecycle method to its modern
useEffectequivalent - Express mount-only, update, and cleanup behavior with dependency arrays
- Prevent memory leaks and race conditions across the lifecycle
- Recognize where
useMemoanduseCallbackoptimize re-renders
Estimated Time: 65 minutes
Practice: Build a lifecycle-logging component and a data fetcher that handles mount, update, and cleanup correctly.
In This Lesson
The Three Phases
Think of a component like a plant. It's planted (mounting), it grows and responds to sun and water (updating), and one day it's pulled from the soil (unmounting). React gives you precise moments to hook into each phase β and in modern React, they all flow through useState and useEffect.
born / first render] A --> C[Updating
props or state change] A --> D[Unmounting
removed from the UI] B --> E[Set up: fetch data,
subscribe, start timers] C --> F[React to changes:
re-fetch, re-compute] D --> G[Clean up: cancel,
unsubscribe, clear]
π‘ The whole lifecycle in one sentence
A component mounts once, updates any number of times as its props or state change, and unmounts once. Your effects set things up on mount, keep them in sync on update, and tear them down on unmount.
Render, Commit & Effect
Behind each phase, React runs the same three steps. Understanding this order explains why effects run when they do and why you never see a flash of stale UI.
On mount, all three run for the first time. On each update, they run again β but React first runs the cleanup of the previous effect before the new setup. On unmount, only cleanup runs. That single ordering rule governs the entire lifecycle.
Lifecycle with Hooks
Modern React has no named lifecycle methods. Instead, one Hook β useEffect β expresses every phase, and the dependency array decides which phase you're targeting.
| Phase | What you want | How to write it with useEffect |
|---|---|---|
| Mount | Run setup once | useEffect(() => {...}, []) |
| Update | Run when a value changes | useEffect(() => {...}, [value]) |
| Every render | Run after each render | useEffect(() => {...}) |
| Unmount | Clean up | return a function from the effect |
A single effect can cover mount, update, and unmount at once β setup runs on mount and relevant updates, the returned cleanup runs before each re-run and on unmount:
function Profile({ id }) {
const [data, setData] = useState(null);
useEffect(() => {
// Mount + every time `id` changes (update)
let ignore = false;
fetchData(id).then(result => { if (!ignore) setData(result); });
// Runs before the next effect and on unmount
return () => { ignore = true; };
}, [id]);
return <div>{data ? data.name : 'Loadingβ¦'}</div>;
}
Class Methods (Brief History)
Before Hooks (React 16.8, 2019), lifecycle logic lived in class components with named methods. You'll still meet them in older codebases, so it helps to recognize the mapping β but you should write new code with hooks.
| Class lifecycle method | Hooks equivalent |
|---|---|
componentDidMount | useEffect(fn, []) |
componentDidUpdate | useEffect(fn, [deps]) |
componentWillUnmount | the cleanup function returned from useEffect |
this.state / this.setState | useState |
π‘ Why hooks won
In classes, one concern (say, a subscription) was split across three methods β set up in componentDidMount, torn down in componentWillUnmount, patched in componentDidUpdate. With hooks, that entire concern lives in one useEffect: setup and cleanup side by side. Related code stays together.
For completeness, here's a class component and its hook equivalent β you don't need to write the class version, just read it:
// OLD: class component (recognize it, don't write it)
class LifecycleClass extends React.Component {
state = { data: null };
componentDidMount() { fetchData(this.props.id).then(d => this.setState({ data: d })); }
componentDidUpdate(prev) {
if (prev.id !== this.props.id) {
fetchData(this.props.id).then(d => this.setState({ data: d }));
}
}
componentWillUnmount() { cancelDataFetch(); }
render() { return <div>{this.state.data}</div>; }
}
// MODERN: one function, one effect covers all three phases
function LifecycleHooks({ id }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchData(id).then(setData); // mount + update
return () => cancelDataFetch(); // unmount cleanup
}, [id]);
return <div>{data}</div>;
}
Mounting: Setup
Mounting is the component's first appearance. This is where you kick off one-time setup: fetch initial data, open a subscription, start a timer, focus an input.
function NewsFeed() {
const [posts, setPosts] = useState([]);
useEffect(() => {
// Runs once, right after the first render
fetch('/api/posts')
.then(res => res.json())
.then(setPosts);
}, []); // empty deps β mount only
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
β οΈ Strict Mode double-invokes effects in development
In development, React 18+ Strict Mode intentionally mounts, unmounts, and remounts each component once, so your effect runs twice. This is a feature: it surfaces missing cleanup. If your effect is idempotent and cleans up properly, the double-run is harmless β and it doesn't happen in production.
Updating: Reacting to Change
A component updates whenever its state or props change. Put values in the dependency array to re-run an effect precisely when they change β and split unrelated reactions into separate effects.
function SearchResults({ query, category }) {
const [results, setResults] = useState([]);
// Effect A: re-run only when `query` changes
useEffect(() => {
if (!query) return;
searchByQuery(query).then(setResults);
}, [query]);
// Effect B: independent β re-run only when `category` changes
useEffect(() => {
if (!category) return;
filterByCategory(category).then(setResults);
}, [category]);
return <ResultsList results={results} />;
}
β Separate concerns, separate effects
Two unrelated triggers deserve two effects. Each has its own dependency array, runs only when its value changes, and can be read and cleaned up on its own. Cramming both into one effect would re-run search when only the category changed.
Unmounting: Cleanup
When a component leaves the screen, React runs the cleanup function of each effect. Skip it and you leak: timers keep ticking, listeners keep firing, and late network responses try to set state on a component that's gone.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true; // track whether we still care about the result
async function load() {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (mounted) { // guard: skip if unmounted mid-flight
setUser(data);
setLoading(false);
}
}
load();
return () => { mounted = false; }; // cleanup on unmount / userId change
}, [userId]);
if (loading) return <p>Loadingβ¦</p>;
if (!user) return <p>User not found</p>;
return <h2>{user.name}</h2>;
}
Timers and event listeners follow the same discipline β start on mount, stop on unmount:
useEffect(() => {
const onScroll = () => console.log(window.scrollY);
window.addEventListener('scroll', onScroll);
const id = setInterval(() => console.log('tick'), 1000);
return () => {
window.removeEventListener('scroll', onScroll);
clearInterval(id);
};
}, []);
Optimizing the Lifecycle
Because a component re-runs on every update, expensive work inside it repeats too. Two memoization hooks let you skip that work when inputs haven't changed. Reach for them after you measure a real slowdown, not by default.
useMemo β cache an expensive value
function ProductList({ items, filter }) {
// Only recompute when items or filter actually change
const visible = useMemo(() =>
items.filter(i => i.name.toLowerCase().includes(filter.toLowerCase())),
[items, filter]
);
return (
<ul>
{visible.map(i => <li key={i.id}>{i.name}</li>)}
</ul>
);
}
useCallback β keep a function stable
Functions are recreated every render. If you pass one to a memoized child, a fresh function each time defeats the memo. useCallback returns the same function until its dependencies change.
const Child = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// Stable across renders β Child won't re-render needlessly
const handleClick = useCallback(() => setCount(c => c + 1), []);
return <Child onClick={handleClick} />;
}
π‘ Don't optimize prematurely
Most components are fast enough without useMemo/useCallback, and wrapping everything adds noise and its own tiny cost. Profile first with the React DevTools Profiler; optimize the components that actually show up as slow.
Practice & Quiz
ποΈ Exercise 1: A lifecycle logger
Goal: Write a component that logs "mounted" once, "updated" on each change to a count it owns, and "will unmount" when removed. Use useEffect for all three.
function LifecycleLogger() {
// TODO: count state + a button to increment it
// TODO: log "mounted" on mount and "will unmount" on unmount
// TODO: log "updated" whenever count changes (but not on mount)
}
π‘ Hint
Use one effect with [] for mount/unmount logging (the return handles unmount). Use a second effect with [count] for updates, and a useRef flag to skip its very first run so "updated" doesn't log on mount.
β Solution
function LifecycleLogger() {
const [count, setCount] = useState(0);
const mounted = useRef(false);
useEffect(() => {
console.log('mounted');
return () => console.log('will unmount');
}, []);
useEffect(() => {
if (mounted.current) {
console.log('updated β count is', count);
} else {
mounted.current = true; // skip the mount render
}
}, [count]);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
ποΈ Exercise 2: Data fetcher with full lifecycle
Goal: A DataFetcher that fetches on mount, re-fetches when its endpoint prop changes, shows loading and error states, and cancels a stale request on unmount.
β Solution
function DataFetcher({ endpoint }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setLoading(true);
setError(null);
fetch(endpoint)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { if (!ignore) setData(json); })
.catch(err => { if (!ignore) setError(err.message); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; }; // cancel stale run
}, [endpoint]);
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p>Error: {error}</p>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
π― Quick Quiz
Question 1: Which useEffect setup matches the old componentDidMount?
Question 2: In what order does React work through an update?
Question 3: Why does an effect run twice on mount in development?
Best Practices & Pitfalls
β Do
- Keep each concern in its own effect β setup and cleanup together
- Always clean up subscriptions, timers, and listeners on unmount
- Guard async results with an
ignore/mountedflag orAbortController - Write new components as functions with hooks, not classes
- Profile before adding
useMemo/useCallback
β Don't
- Reach for class lifecycle methods in new code
- Merge unrelated logic into one giant effect
- Assume a double-run in dev is a bug β fix the missing cleanup instead
- Set state after unmount (that's what the guard flag prevents)
- Sprinkle memoization everywhere "just in case"
β οΈ Error boundaries are still class-only
One lifecycle feature has no hook yet: catching render errors in children. That still requires a class component with getDerivedStateFromError and componentDidCatch (or a ready-made library like react-error-boundary). It's the rare case where you'll write a class in modern React.
Summary
π Key Takeaways
- Components live through three phases: mounting, updating, and unmounting
- Each phase runs React's render β commit β effect sequence
useEffectplus a dependency array expresses every phase:[]= mount,[deps]= update, cleanup = unmount- Class methods map cleanly to hooks β but write new code with function components + hooks
- Always clean up to prevent leaks and race conditions; guard async state updates
useMemoanduseCallbackoptimize re-renders β use them after profiling
π Additional Resources
- react.dev β Lifecycle of Reactive Effects
- react.dev β Class lifecycle methods (legacy reference)
- react.dev β
useMemoreference - react.dev β
useCallbackreference
π What's Next?
You now understand how components live and how effects sync with each phase. Next we make components truly interactive by handling user input directly: Event Handling in React β clicks, form submissions, keyboard input, and the synthetic event system that ties it all together.
π You see the whole life of a component now!
Mount, update, unmount β and the cleanup that keeps your apps leak-free. This is the mental model senior React developers rely on every day.