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:

  1. Dependency Comparison: React compares the dependency array between renders
  2. Effect Scheduling: Effects are scheduled to run after the DOM has been updated
  3. Cleanup Management: Previous effects are cleaned up before new ones run
  4. 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 Example
function 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 Example
function 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 Example
function 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 Example
function 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 Example
function 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 Example
function 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-renders
function BadExample() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Dependencies missing!
});
}
// ✅ Proper dependency management
function 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 problem
function 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 dependencies
function 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:

  1. Encapsulate complex effect logic into reusable hooks
  2. Add specialized behavior like debouncing, conditional execution, or async handling
  3. Improve code organization by abstracting common patterns
  4. Enhance debugging with custom error handling and logging
  5. 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!

Share Feedback