Explain the React component lifecycle in function components. How do useEffect dependencies work, and what are common pitfalls?
Quick Answer
React function components use hooks to manage lifecycle behavior, replacing the traditional class component lifecycle methods. The main lifecycle hook is useEffect, which handles side effects and cleanup.
Detailed Answer
Explain the React component lifecycle in function components. How do useEffect dependencies work, and what are common pitfalls?
Answer:
React function components use hooks to manage lifecycle behavior, replacing the traditional class component lifecycle methods. The main lifecycle hook is useEffect, which handles side effects and cleanup.
Function Component Lifecycle Phases:
- Mount Phase:
function MyComponent() {
// 1. Component function runs (like constructor)
const [state, setState] = useState(initialValue);
// 2. useEffect with empty dependency array runs (like componentDidMount)
useEffect(() => {
console.log('Component mounted');
// Setup subscriptions, timers, etc.
// 3. Cleanup function (like componentWillUnmount)
return () => {
console.log('Component will unmount');
// Cleanup subscriptions, timers, etc.
};
}, []); // Empty dependency array = run once on mount
// 4. Render phase
return <div>Component content</div>;
}
- Update Phase:
function MyComponent({ userId }) {
const [user, setUser] = useState(null);
// Runs on every render (like componentDidUpdate)
useEffect(() => {
console.log('Component updated');
fetchUser(userId).then(setUser);
}, [userId]); // Runs when userId changes
// Runs on every render (no dependency array)
useEffect(() => {
console.log('Runs on every render');
}); // No dependency array = runs on every render
return <div>{user?.name}</div>;
}
- Unmount Phase:
function MyComponent() {
useEffect(() => {
const timer = setInterval(() => {
console.log('Timer tick');
}, 1000);
// Cleanup function runs on unmount
return () => {
clearInterval(timer);
console.log('Timer cleaned up');
};
}, []);
return <div>Component with timer</div>;
}
useEffect Dependencies Deep Dive:
The dependency array controls when the effect runs:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
// 1. Empty array - runs once on mount
useEffect(() => {
console.log('Component mounted');
}, []);
// 2. Specific dependencies - runs when dependencies change
useEffect(() => {
if (userId) {
fetchUser(userId).then(setUser);
}
}, [userId]); // Runs when userId changes
// 3. No dependency array - runs on every render
useEffect(() => {
console.log('Runs on every render');
});
// 4. Multiple dependencies
useEffect(() => {
if (user && user.isActive) {
fetchUserPosts(user.id).then(setPosts);
}
}, [user]); // Runs when user object changes
// 5. Function dependencies (be careful!)
const fetchData = useCallback(() => {
return fetchUser(userId);
}, [userId]);
useEffect(() => {
fetchData().then(setUser);
}, [fetchData]); // Runs when fetchData function changes
}
Common useEffect Pitfalls:
- Missing Dependencies:
// BAD - Missing userId dependency
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // userId not in dependencies!
}, []); // Empty array - will only run once
return <div>{user?.name}</div>;
}
// GOOD - Include all dependencies
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // userId included in dependencies
return <div>{user?.name}</div>;
}
- Infinite Re-render Loops:
// BAD - Creates new object on every render
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
setData({ timestamp: Date.now() }); // New object every time!
}, [data]); // data changes, effect runs, data changes again...
return <div>{data?.timestamp}</div>;
}
// GOOD - Use useCallback or move object creation inside effect
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
setData({ timestamp: Date.now() });
}, []); // Run once on mount
return <div>{data?.timestamp}</div>;
}
- Stale Closures:
// BAD - Stale closure problem
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Always uses initial count value (0)
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array
return <div>{count}</div>;
}
// GOOD - Use functional updates
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => prevCount + 1); // Uses latest count value
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
- Object/Array Dependencies:
// BAD - New object/array on every render
function MyComponent({ filters }) {
const [data, setData] = useState([]);
useEffect(() => {
fetchData(filters).then(setData);
}, [filters]); // filters is a new object every render
return <div>{data.length} items</div>;
}
// GOOD - Use useMemo for stable references
function MyComponent({ filters }) {
const [data, setData] = useState([]);
const stableFilters = useMemo(() => filters, [
filters.category,
filters.status,
filters.dateRange
]);
useEffect(() => {
fetchData(stableFilters).then(setData);
}, [stableFilters]);
return <div>{data.length} items</div>;
}
Follow-up: How would you implement componentDidMount, componentDidUpdate, and componentWillUnmount behavior using hooks?
Answer: Here's how to replicate class component lifecycle methods using hooks:
// Class component equivalent
class MyClassComponent extends React.Component {
componentDidMount() {
console.log('Component mounted');
this.setupSubscriptions();
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.userId !== this.props.userId) {
this.fetchUserData();
}
}
componentWillUnmount() {
console.log('Component will unmount');
this.cleanupSubscriptions();
}
render() {
return <div>Class component</div>;
}
}
// Function component equivalent
function MyFunctionComponent({ userId }) {
const [userData, setUserData] = useState(null);
const prevUserIdRef = useRef();
// componentDidMount equivalent
useEffect(() => {
console.log('Component mounted');
setupSubscriptions();
// componentWillUnmount equivalent
return () => {
console.log('Component will unmount');
cleanupSubscriptions();
};
}, []); // Empty dependency array = mount/unmount only
// componentDidUpdate equivalent
useEffect(() => {
if (prevUserIdRef.current !== userId) {
fetchUserData(userId).then(setUserData);
prevUserIdRef.current = userId;
}
}, [userId]);
return <div>Function component</div>;
}
Advanced Lifecycle Patterns:
- Custom Hook for Lifecycle:
function useLifecycle({
onMount,
onUpdate,
onUnmount,
dependencies = []
}) {
const isFirstRender = useRef(true);
const prevDeps = useRef(dependencies);
// Mount effect
useEffect(() => {
if (onMount) {
onMount();
}
return () => {
if (onUnmount) {
onUnmount();
}
};
}, []);
// Update effect
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (onUpdate) {
onUpdate(prevDeps.current, dependencies);
}
prevDeps.current = dependencies;
}, dependencies);
}
// Usage
function MyComponent({ userId }) {
useLifecycle({
onMount: () => console.log('Mounted'),
onUpdate: (prevDeps, currentDeps) => {
if (prevDeps[0] !== currentDeps[0]) {
console.log('userId changed');
}
},
onUnmount: () => console.log('Unmounting'),
dependencies: [userId]
});
return <div>Component with custom lifecycle</div>;
}
- Conditional Effects:
function ConditionalComponent({ shouldFetch, userId }) {
// Only run effect when shouldFetch is true
useEffect(() => {
if (shouldFetch && userId) {
fetchUserData(userId);
}
}, [shouldFetch, userId]);
// Early return pattern
if (!shouldFetch) {
return <div>Fetching disabled</div>;
}
return <div>Fetching enabled</div>;
}
- Effect Cleanup Patterns:
function DataFetcher({ url }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const abortControllerRef = useRef();
useEffect(() => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
setLoading(true);
fetch(url, { signal: abortControllerRef.current.signal })
.then(response => response.json())
.then(setData)
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
})
.finally(() => setLoading(false));
// Cleanup function
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url]);
return <div>{loading ? 'Loading...' : JSON.stringify(data)}</div>;
}
Best Practices:
- Always include dependencies:
// Use ESLint plugin to catch missing dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
// Only if you're absolutely sure about the dependencies
}, []);
- Use useCallback for stable function references:
function MyComponent({ onDataChange }) {
const [data, setData] = useState(null);
const handleDataChange = useCallback((newData) => {
setData(newData);
onDataChange?.(newData);
}, [onDataChange]);
useEffect(() => {
// handleDataChange is stable
setupDataListener(handleDataChange);
}, [handleDataChange]);
return <div>{data}</div>;
}
- Separate concerns with multiple effects:
function ComplexComponent({ userId, settings }) {
// Separate effects for different concerns
useEffect(() => {
// User data fetching
fetchUser(userId);
}, [userId]);
useEffect(() => {
// Settings synchronization
syncSettings(settings);
}, [settings]);
useEffect(() => {
// Analytics tracking
trackPageView();
}, []); // Run once on mount
return <div>Complex component</div>;
}
Understanding useEffect dependencies and lifecycle patterns is crucial for building reliable React applications. The key is to think about when effects should run and ensure proper cleanup to prevent memory leaks.