Creating Your Own useEffect Hook
While React’s built-in useEffect is powerful and handles most use cases, understanding how to create your own effect hooks can deepen your understanding of React’s internals and help you build specialized solutions for specific needs.
In this guide, we’ll explore how to create custom useEffect variations, understand the underlying principles, and build practical custom hooks that extend useEffect’s functionality.
Understanding useEffect Internals
Before we create our own version, let’s understand what useEffect does under the hood:
- Dependency Comparison: React compares the dependency array between renders
- Effect Scheduling: Effects are scheduled to run after the DOM has been updated
- Cleanup Management: Previous effects are cleaned up before new ones run
- Timing Control: Effects run asynchronously after render commits
Building a Basic useEffect Clone
Let’s start by creating a simplified version of useEffect to understand its core mechanics:
import { useRef, useLayoutEffect } from 'react';
function useCustomEffect(effect, dependencies) { const hasMount = useRef(false); const prevDeps = useRef(); const cleanup = useRef();
// Helper function to compare dependencies const depsChanged = (prevDeps, nextDeps) => { if (prevDeps === undefined) return true; if (nextDeps === undefined) return true; if (prevDeps.length !== nextDeps.length) return true;
return prevDeps.some((dep, index) => !Object.is(dep, nextDeps[index]) ); };
useLayoutEffect(() => { // Check if this is the first mount or dependencies changed if (!hasMount.current || depsChanged(prevDeps.current, dependencies)) { // Clean up previous effect if (cleanup.current) { cleanup.current(); cleanup.current = undefined; }
// Run the new effect const cleanupFn = effect(); if (typeof cleanupFn === 'function') { cleanup.current = cleanupFn; }
// Update refs hasMount.current = true; prevDeps.current = dependencies; } });
// Cleanup on unmount useLayoutEffect(() => { return () => { if (cleanup.current) { cleanup.current(); } }; }, []);}Usage Example
function Counter() { const [count, setCount] = useState(0);
// Using our custom effect useCustomEffect(() => { console.log('Count changed:', count);
return () => { console.log('Cleaning up for count:', count); }; }, [count]);
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(c => c + 1)}>Increment</button> </div> );}Advanced Custom Effect Hooks
Now let’s build some practical custom effect hooks that extend useEffect’s functionality:
1. useDebounceEffect - Debounced Effects
This hook delays effect execution until after a specified delay:
import { useEffect, useRef } from 'react';
function useDebounceEffect(effect, dependencies, delay = 300) { const timeoutRef = useRef(); const cleanupRef = useRef();
useEffect(() => { // Clear existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); }
// Clear previous effect cleanup if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = undefined; }
// Set new timeout timeoutRef.current = setTimeout(() => { const cleanup = effect(); if (typeof cleanup === 'function') { cleanupRef.current = cleanup; } }, delay);
// Cleanup function return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (cleanupRef.current) { cleanupRef.current(); } }; }, [...dependencies, delay]);}
// Usage Examplefunction SearchComponent() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]);
useDebounceEffect(() => { if (query) { console.log('Searching for:', query); // Simulate API call fetch(`/api/search?q=${query}`) .then(res => res.json()) .then(setResults); } }, [query], 500);
return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <ul> {results.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul> </div> );}2. useAsyncEffect - Async Effects with Cancellation
Handle async operations with proper cancellation:
import { useEffect, useRef } from 'react';
function useAsyncEffect(asyncEffect, dependencies) { const cancelRef = useRef();
useEffect(() => { // Cancel previous async operation if (cancelRef.current) { cancelRef.current(); }
let cancelled = false;
// Create cancel function cancelRef.current = () => { cancelled = true; };
// Execute async effect const executeAsync = async () => { try { const cleanup = await asyncEffect(() => cancelled);
// Only set cleanup if not cancelled if (!cancelled && typeof cleanup === 'function') { const prevCancel = cancelRef.current; cancelRef.current = () => { prevCancel(); cleanup(); }; } } catch (error) { if (!cancelled) { console.error('Async effect error:', error); } } };
executeAsync();
return () => { if (cancelRef.current) { cancelRef.current(); } }; }, dependencies);}
// Usage Examplefunction UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(false);
useAsyncEffect(async (isCancelled) => { setLoading(true);
try { const response = await fetch(`/api/users/${userId}`);
// Check if cancelled before updating state if (isCancelled()) return;
const userData = await response.json();
if (isCancelled()) return;
setUser(userData); } finally { if (!isCancelled()) { setLoading(false); } }
// Return cleanup function return () => { console.log('Cleaning up user fetch'); }; }, [userId]);
if (loading) return <div>Loading...</div>; if (!user) return <div>No user found</div>;
return <div>Hello, {user.name}!</div>;}3. useConditionalEffect - Conditional Effect Execution
Run effects only when specific conditions are met:
function useConditionalEffect(effect, dependencies, condition) { const prevCondition = useRef(); const hasRun = useRef(false);
useEffect(() => { const shouldRun = typeof condition === 'function' ? condition() : condition;
// Run effect if: // 1. Condition is true AND dependencies changed // 2. Condition changed from true to false (for cleanup) if (shouldRun && (!hasRun.current || dependencies)) { const cleanup = effect(); hasRun.current = true;
return cleanup; } else if (!shouldRun && prevCondition.current) { // Condition changed from true to false, run cleanup hasRun.current = false; }
prevCondition.current = shouldRun; }, [...dependencies, condition]);}
// Usage Examplefunction ChatComponent({ isLoggedIn, chatId }) { const [messages, setMessages] = useState([]);
useConditionalEffect(() => { console.log('Connecting to chat:', chatId);
const ws = new WebSocket(`ws://chat-server/${chatId}`);
ws.onmessage = (event) => { const message = JSON.parse(event.data); setMessages(prev => [...prev, message]); };
return () => { console.log('Disconnecting from chat'); ws.close(); }; }, [chatId], isLoggedIn); // Only connect if logged in
return ( <div> {isLoggedIn ? ( <div> {messages.map(msg => ( <div key={msg.id}>{msg.text}</div> ))} </div> ) : ( <div>Please log in to view chat</div> )} </div> );}4. useEffectOnce - Run Effect Only Once
A hook that ensures an effect runs only once, regardless of re-renders:
function useEffectOnce(effect) { const hasRun = useRef(false); const cleanup = useRef();
useEffect(() => { if (!hasRun.current) { hasRun.current = true; const cleanupFn = effect();
if (typeof cleanupFn === 'function') { cleanup.current = cleanupFn; } }
return () => { if (cleanup.current) { cleanup.current(); cleanup.current = undefined; } }; }, []); // Empty dependency array, but we control execution with ref}
// Usage Examplefunction Analytics() { useEffectOnce(() => { console.log('Initializing analytics - this runs only once');
// Initialize analytics window.gtag('config', 'GA_MEASUREMENT_ID');
return () => { console.log('Cleaning up analytics'); }; });
return <div>Analytics initialized</div>;}5. useUpdateEffect - Skip First Render
Run effect only on updates, not on initial mount:
function useUpdateEffect(effect, dependencies) { const isFirstRender = useRef(true);
useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; }
return effect(); }, dependencies);}
// Usage Examplefunction UserSettings({ settings }) { const [localSettings, setLocalSettings] = useState(settings);
// Only save to localStorage on updates, not initial load useUpdateEffect(() => { console.log('Saving settings to localStorage'); localStorage.setItem('userSettings', JSON.stringify(localSettings)); }, [localSettings]);
return ( <div> <input value={localSettings.theme} onChange={(e) => setLocalSettings(prev => ({ ...prev, theme: e.target.value }))} /> </div> );}Building a Comprehensive Custom Effect Hook
Let’s combine several concepts into a powerful, configurable effect hook:
function useAdvancedEffect(effect, options = {}) { const { dependencies = [], debounce = 0, condition = true, skipFirstRender = false, onError = console.error } = options;
const timeoutRef = useRef(); const cleanupRef = useRef(); const isFirstRender = useRef(true); const hasRun = useRef(false);
useEffect(() => { // Skip first render if requested if (skipFirstRender && isFirstRender.current) { isFirstRender.current = false; return; }
// Check condition const shouldRun = typeof condition === 'function' ? condition() : condition; if (!shouldRun) return;
// Clear existing timeout and cleanup if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = undefined; }
const executeEffect = async () => { try { const cleanup = await effect(); if (typeof cleanup === 'function') { cleanupRef.current = cleanup; } } catch (error) { onError(error); } };
if (debounce > 0) { timeoutRef.current = setTimeout(executeEffect, debounce); } else { executeEffect(); }
hasRun.current = true; isFirstRender.current = false;
return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (cleanupRef.current) { cleanupRef.current(); } }; }, [...dependencies, condition, debounce, skipFirstRender]);}
// Usage Examplefunction AdvancedComponent({ userId, isActive }) { const [data, setData] = useState(null);
useAdvancedEffect( async () => { console.log('Fetching user data...'); const response = await fetch(`/api/users/${userId}`); const userData = await response.json(); setData(userData);
return () => { console.log('Cleaning up user data fetch'); }; }, { dependencies: [userId], debounce: 300, condition: isActive, skipFirstRender: true, onError: (error) => { console.error('Failed to fetch user data:', error); setData(null); } } );
return ( <div> {data ? ( <div>User: {data.name}</div> ) : ( <div>Loading...</div> )} </div> );}Best Practices for Custom Effect Hooks
1. Always Handle Cleanup
function useWebSocket(url) { const [socket, setSocket] = useState(null);
useEffect(() => { const ws = new WebSocket(url); setSocket(ws);
// Always provide cleanup return () => { ws.close(); setSocket(null); }; }, [url]);
return socket;}2. Use Refs for Mutable Values
function useInterval(callback, delay) { const savedCallback = useRef();
// Remember the latest callback useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => { function tick() { savedCallback.current(); }
if (delay !== null) { const id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]);}3. Provide Flexible APIs
function useLocalStorage(key, initialValue, options = {}) { const { serialize = JSON.stringify, deserialize = JSON.parse } = options;
const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item ? deserialize(item) : initialValue; } catch (error) { console.error(`Error reading localStorage key "${key}":`, error); return initialValue; } });
const setValue = (value) => { try { setStoredValue(value); window.localStorage.setItem(key, serialize(value)); } catch (error) { console.error(`Error setting localStorage key "${key}":`, error); } };
return [storedValue, setValue];}Common Pitfalls and Solutions
1. Infinite Re-renders
// ❌ This will cause infinite re-rendersfunction BadExample() { const [count, setCount] = useState(0);
useEffect(() => { setCount(count + 1); // Dependencies missing! });}
// ✅ Proper dependency managementfunction GoodExample() { const [count, setCount] = useState(0);
useEffect(() => { const timer = setTimeout(() => { setCount(c => c + 1); // Use functional update }, 1000);
return () => clearTimeout(timer); }, []); // Empty dependencies for one-time setup}2. Stale Closures
// ❌ Stale closure problemfunction BadTimer() { const [count, setCount] = useState(0);
useEffect(() => { const timer = setInterval(() => { setCount(count + 1); // This captures the initial count value }, 1000);
return () => clearInterval(timer); }, []); // Missing count dependency}
// ✅ Use functional updates or include dependenciesfunction GoodTimer() { const [count, setCount] = useState(0);
useEffect(() => { const timer = setInterval(() => { setCount(c => c + 1); // Always gets latest value }, 1000);
return () => clearInterval(timer); }, []); // No dependencies needed with functional update}Testing Custom Effect Hooks
import { renderHook, act } from '@testing-library/react-hooks';
describe('useDebounceEffect', () => { beforeEach(() => { jest.useFakeTimers(); });
afterEach(() => { jest.useRealTimers(); });
it('should debounce effect execution', () => { const effect = jest.fn(); const { rerender } = renderHook( ({ value }) => useDebounceEffect(effect, [value], 100), { initialProps: { value: 'initial' } } );
// Effect should not run immediately expect(effect).not.toHaveBeenCalled();
// Fast advance time act(() => { jest.advanceTimersByTime(50); }); expect(effect).not.toHaveBeenCalled();
// Complete the debounce period act(() => { jest.advanceTimersByTime(50); }); expect(effect).toHaveBeenCalledTimes(1);
// Change value and test debouncing rerender({ value: 'updated' });
act(() => { jest.advanceTimersByTime(50); }); expect(effect).toHaveBeenCalledTimes(1); // Still only called once
act(() => { jest.advanceTimersByTime(50); }); expect(effect).toHaveBeenCalledTimes(2); // Now called again });});Conclusion
Creating custom useEffect hooks allows you to:
- Encapsulate complex effect logic into reusable hooks
- Add specialized behavior like debouncing, conditional execution, or async handling
- Improve code organization by abstracting common patterns
- Enhance debugging with custom error handling and logging
- Build domain-specific solutions tailored to your application’s needs
Remember these key principles when building custom effect hooks:
- Always handle cleanup properly
- Use refs for values that shouldn’t trigger re-renders
- Provide flexible, well-documented APIs
- Test your hooks thoroughly
- Consider performance implications
- Follow React’s rules of hooks
By mastering custom effect hooks, you’ll have powerful tools to handle complex side effects and create more maintainable React applications.
Next Steps
Now that you understand how to create custom useEffect hooks, you’re ready to explore other advanced hook patterns like useReducer for complex state management, or dive into performance optimization techniques with useMemo and useCallback.
The ability to create custom hooks is one of React’s most powerful features - use it wisely to build better abstractions for your applications!