Building Your First Custom React Hook
Why Create Custom Hooks?
Custom hooks are one of React's most powerful features - they let you extract component logic into reusable functions. Instead of copying and pasting the same state logic between components, you can wrap it in a hook and share it across your app. Let's learn how to build one!
A Practical Example: useLocalStorage
We'll build a useLocalStorage
hook that makes it easy to persist state in localStorage. It'll work just like useState
, but automatically save and load values from localStorage. Try the demo below:
Try the useLocalStorage Hook
Values persist even after page refresh. Open your browser's dev tools and check localStorage!
localStorage Contents:
{ "name": "", "age": "", "theme": "light" }
Breaking Down the Hook
Let's build this hook step by step:
1. The Basic Structure
First, let's set up the hook's basic structure:
function useLocalStorage<T>(key: string, initialValue: T) {
// We'll implement this next
return [value, setValue];
}
Notice a few things:
- We use TypeScript generics (
<T>
) to make our hook type-safe - We take a storage key and initial value as parameters
- Like useState, we'll return a value and a setter function
2. Reading from localStorage
Next, let's implement the logic to read from localStorage:
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
Here's what's happening:
- We use a function with useState to only run this logic once
- We check if window exists (important for Next.js/SSR)
- We try to get and parse the stored value, falling back to initialValue
3. Writing to localStorage
Now let's implement the setter function:
const setValue = (value: T | ((val: T) => T)) => {
try {
// Handle function updates
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.log(error);
}
};
Key points about the setter:
- It handles both direct values and updater functions (like useState)
- It updates both the state and localStorage
- It includes error handling for localStorage failures
Best Practices for Custom Hooks
1. Follow the Rules of Hooks
Remember that custom hooks must follow the same rules as regular hooks:
- Only call hooks at the top level
- Only call hooks from React functions
- Start your hook name with "use"
2. Handle Edge Cases
Good hooks should handle common issues:
- Server-side rendering (check for window)
- Error states (try/catch blocks)
- Loading states when needed
- Type safety with TypeScript
3. Keep It Focused
A good hook should do one thing well. If your hook is getting complex, consider splitting it into multiple hooks.
Testing Custom Hooks
Here's how to test our useLocalStorage hook:
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('should store and retrieve values', () => {
const { result } = renderHook(() =>
useLocalStorage('test-key', 'initial')
);
expect(result.current[0]).toBe('initial');
act(() => {
result.current[1]('new value');
});
expect(result.current[0]).toBe('new value');
expect(localStorage.getItem('test-key')).toBe('"new value"');
});
});
Common Use Cases
Custom hooks are great for:
- Data fetching and caching
- Form handling
- Browser API interactions
- Animation logic
- State management patterns
Advanced Patterns
1. Composition
You can compose hooks together:
function usePersistedState<T>(key: string, initial: T) {
const [value, setValue] = useLocalStorage(key, initial);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(false);
}, []);
return { value, setValue, isLoading };
}
2. Event Handling
Listen for storage events to sync across tabs:
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === key) {
setStoredValue(JSON.parse(e.newValue || 'null'));
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, [key]);
Wrapping Up
Custom hooks are a powerful way to share logic between components. They let you build your own abstractions and make your code more reusable and maintainable. Start with simple hooks like our localStorage example, and gradually build more complex ones as you get comfortable with the pattern.
Pro Tip: When building custom hooks, always consider the developer experience. Good error messages, TypeScript support, and clear documentation make your hooks much more useful to others.