Master every phase of the React component lifecycle — mounting, updating, and unmounting. Covers class component methods, their exact hooks equivalents, error boundaries, and the deprecated methods every interviewer still asks about.
Think of a React component's lifecycle like a theatre performance. Mounting is when the actor walks on stage — the component is born, setup happens, the audience sees it for the first time. Updating is the performance itself — the actor reacts to new cues (props or state changes) and adapts. Unmounting is the curtain call — the actor exits and everything brought on stage must be cleared away. Class components gave you named hooks for each moment in that performance; the functional component model unified everything into effects, but the same three-act structure runs underneath.
Every React component, whether a class or a function, goes through three phases:
Class components expose explicit lifecycle methods for each phase. Functional components achieve the same result through hooks — primarily useEffect, useState, and useRef. Both models produce identical behaviour; the syntax is just different.
Four methods fire in this order when a class component first appears in the DOM:
class UserProfile extends React.Component {
constructor(props) {
super(props);
// 1. FIRST — initialise state and bind methods
// Do NOT call setState() or trigger side effects here
this.state = { user: null, loading: true };
}
static getDerivedStateFromProps(props, state) {
// 2. Called before EVERY render (mount and update)
// Return an object to merge into state, or null to change nothing
// Almost always the wrong tool — see Common Mistakes
return null;
}
render() {
// 3. The only required method — must return JSX
// Must be PURE — no side effects, no setState calls
const { user, loading } = this.state;
if (loading) return <Spinner />;
return <div>{user.name}</div>;
}
componentDidMount() {
// 4. LAST — fires after the component is in the DOM
// Safe to: fetch data, add event listeners, access DOM nodes via refs
fetch(`/api/users/${this.props.userId}`)
.then(res => res.json())
.then(user => this.setState({ user, loading: false }));
}
}
When props or state change, this sequence fires:
shouldComponentUpdate(nextProps, nextState) {
// Called before re-render — return false to bail out and skip the render
// React.PureComponent does a shallow prop/state comparison automatically
// Only use this for measurable performance bottlenecks
return nextProps.userId !== this.props.userId;
}
// render() fires again here
getSnapshotBeforeUpdate(prevProps, prevState) {
// Called right BEFORE the DOM is mutated (after render, before commit)
// Whatever you return here is passed as the 3rd argument to componentDidUpdate
// Classic use case: capture scroll position before a list grows
if (prevProps.messages.length < this.props.messages.length) {
return this.listRef.current.scrollHeight;
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
// Called after every re-render and DOM update
// ALWAYS guard setState calls with a condition — otherwise infinite loop
if (prevProps.userId !== this.props.userId) {
this.fetchUser(this.props.userId);
}
// snapshot is the value returned from getSnapshotBeforeUpdate
if (snapshot !== null) {
this.listRef.current.scrollTop =
this.listRef.current.scrollHeight - snapshot;
}
}
componentWillUnmount() {
// Called just before the component is removed from the DOM
// CLEAN UP everything: subscriptions, timers, event listeners, pending fetches
// Do NOT call setState here — the component is being destroyed
clearInterval(this.timerID);
this.socket.close();
window.removeEventListener('resize', this.handleResize);
}
Functional components don't have lifecycle methods — they have effects. Every class lifecycle method maps directly to a hooks pattern:
// Class
constructor(props) {
super(props);
this.state = { count: 0 };
}
// Hooks
const [count, setCount] = useState(0);
// Class
componentDidMount() {
this.fetchUser(this.props.userId);
}
// Hooks — empty array = run once on mount
useEffect(() => {
fetchUser(userId);
}, []);
// Class
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.fetchUser(this.props.userId);
}
}
// Hooks — the dependency array handles the comparison automatically
useEffect(() => {
fetchUser(userId);
}, [userId]); // Re-runs whenever userId changes
// Class
componentWillUnmount() {
this.subscription.unsubscribe();
}
// Hooks — the returned function is the cleanup
useEffect(() => {
const sub = subscribe(userId);
return () => sub.unsubscribe(); // this IS componentWillUnmount
}, []);
// Class
shouldComponentUpdate(nextProps) {
return nextProps.id !== this.props.id; // return false to skip re-render
}
// Hooks — React.memo wraps the component, comparator returns true to SKIP render
// (Note: the boolean is INVERTED compared to shouldComponentUpdate)
const UserCard = React.memo(({ id, name }) => {
return <div>{name}</div>;
}, (prevProps, nextProps) => {
return prevProps.id === nextProps.id; // true = props are equal, skip render
});
The comparator in React.memo returnstrueto skip re-render (props are equal).shouldComponentUpdatereturnstrueto allow re-render. The logic is inverted — this trips up many candidates.
// Hooks approximation — useLayoutEffect fires synchronously before paint
const scrollRef = useRef(null);
const prevLengthRef = useRef(messages.length);
useLayoutEffect(() => {
if (messages.length > prevLengthRef.current) {
// Restore scroll position after new messages are added
scrollRef.current.scrollTop = scrollRef.current.scrollHeight - snapshot;
}
prevLengthRef.current = messages.length;
});
Two lifecycle methods exist for catching errors thrown during rendering in child components. There is no hooks equivalent — you must write a class component as an Error Boundary:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Called during the render phase when a child throws
// Return state update to show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error, info) {
// Called in the commit phase after the fallback UI renders
// Safe to log errors to an external service
logToSentry(error, info.componentStack);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong: {this.state.error.message}</div>;
}
return this.props.children;
}
}
// Usage — wraps any subtree
<ErrorBoundary>
<MyComplexFeature />
</ErrorBoundary>
Error Boundaries only catch errors in child components during rendering, lifecycle methods, and constructors. They do NOT catch errors in event handlers (use try/catch there) or async code.
Three methods were deprecated in React 16.3 and prefixed with UNSAFE_. They still work today but are removed in future React versions. Know them for interviews — many legacy codebases still use them:
UNSAFE_componentWillMount — ran before mount; replace with constructor or useEffect(fn, [])UNSAFE_componentWillReceiveProps — ran when new props arrived; replace with getDerivedStateFromProps or useEffect(fn, [prop])UNSAFE_componentWillUpdate — ran before re-render; replace with getSnapshotBeforeUpdateWhy deprecated? React's concurrent rendering can start a render, pause it, and restart — running these methods multiple times before committing anything to the DOM. Any side effects inside them (data fetching, subscriptions) would fire multiple times unexpectedly, causing subtle bugs.
Functional components don't technically have a "lifecycle" — every render is just a function call that returns JSX. The lifecycle emerges from effects and how React schedules them:
useEffect(fn, []) firesuseEffect(fn, [dep]) fires if dep changed → cleanup of previous effect runs firstIf you have multiple useEffect calls in one component, they run top to bottom in the order they appear in the code.
Many developers think functional components don't have a lifecycle — they do. Mounting, updating, and unmounting all happen exactly the same way; the difference is that hooks express lifecycle as synchronisation effects rather than named callback methods.
Many developers treat componentDidMount and useEffect(fn, []) as identical — they're almost the same, with one difference: in React 18 StrictMode, useEffect fires twice on mount (mount → unmount → mount) to test cleanup. componentDidMount fires once even in StrictMode.
Many developers reach for getDerivedStateFromProps when props change and they want to update state — this is almost always wrong. If you can compute a value from props, compute it inline during render: const derivedValue = computeFrom(props). getDerivedStateFromProps was added to handle rare edge cases like a controlled animation component that resets on prop change.
Many developers call setState in componentDidUpdate without a condition — this creates an infinite loop: update → componentDidUpdate → setState → update → componentDidUpdate. Always wrap setState in a condition that compares prevProps or prevState.
Many developers think React.memo's comparator works the same as shouldComponentUpdate — the boolean is inverted. shouldComponentUpdate returns true to ALLOW a render. React.memo's comparator returns true to SKIP a render (meaning props are equal). Mixing this up is a very common interview mistake.
Many developers think Error Boundaries catch all errors — they only catch errors thrown during rendering, in lifecycle methods, and in constructors of child components. They do not catch errors in event handlers, async code (setTimeout, fetch), or server-side rendering.
Data fetching on mount: every user profile page, dashboard, and product detail page uses componentDidMount (class) or useEffect(fn, [id]) (hooks) to fetch data when the component first appears or when a route param changes.
Subscription teardown: real-time features (WebSocket feeds, Firestore listeners, Redux store subscriptions) set up in componentDidMount and torn down in componentWillUnmount — or in useEffect's cleanup function — to prevent memory leaks and duplicate listeners.
Scroll position restoration: chat applications use getSnapshotBeforeUpdate to capture the scroll height before new messages are appended, then adjust scrollTop in componentDidUpdate so the view doesn't jump — the only lifecycle method that has a clean hooks equivalent requiring useLayoutEffect.
Error Boundaries in production apps: every serious React application wraps feature sections in an ErrorBoundary so a crash in one widget (a chart, a media player) doesn't take down the whole page. Libraries like react-error-boundary provide a ready-made wrapper.
Performance optimisation with shouldComponentUpdate: heavy list items (complex cards, table rows) in class components use shouldComponentUpdate or extend React.PureComponent to skip re-renders when props haven't changed — the hooks equivalent is wrapping with React.memo.
Third-party library teardown: map libraries (Mapbox, Leaflet), rich text editors (Quill, TipTap), and charting libraries (D3, Chart.js) initialise in componentDidMount and call their destroy/remove methods in componentWillUnmount to free GPU memory and detach DOM nodes.
What is the component lifecycle and how do hooks map to it?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.