Difficulty: Advanced
Hook composition is the practice of building complex hooks by combining simpler hooks together. This is one of the most powerful patterns in React because it allows you to create layers of abstraction that are easy to test, reuse, and understand. Each hook in the composition chain handles one concern, and they combine to solve complex problems elegantly.
The useDebounce hook is a classic composition pattern. It combines useState with useEffect to delay updating a value until a period of inactivity. This is essential for search-as-you-type features, window resize handlers, and any scenario where you want to wait until the user stops performing an action before reacting. The hook takes a value and a delay, and returns a debounced version that only updates after the delay elapses without the input changing.
useLocalStorage is another foundational composed hook. It wraps useState with useEffect to synchronize state with localStorage. On initial load, it reads from localStorage (with a fallback to the provided default). On every state change, it writes the new value to localStorage. This pattern is reusable for any piece of state that should persist across page reloads - user preferences, form drafts, theme settings, and more.
More advanced composition patterns include hooks that combine useReducer with useContext for state management, hooks that wrap useRef and useEffect for intersection observation or resize detection, and hooks that compose multiple custom hooks together. For example, a usePaginatedSearch hook might compose useDebounce (for the search input), useFetch (for the API call), and useReducer (for pagination state).
The key principle of hook composition is separation of concerns. Each hook should do one thing well. A useDebounce hook knows nothing about search - it just debounces values. A useFetch hook knows nothing about pagination - it just fetches data. By composing them, you get debounced paginated search without any single hook being overly complex. This modularity makes each piece independently testable and reusable in different contexts.
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Demonstration
function SearchInput() {
const [query, setQuery] = React.useState('react hooks');
const debouncedQuery = useDebounce(query, 300);
console.log('Input value:', query);
console.log('Debounced value:', debouncedQuery);
console.log('Debounce delays updates by 300ms');
return null;
}
SearchInput();
useDebounce combines useState and useEffect. The effect sets a timeout that updates the debounced value. If the value changes before the timeout fires, the cleanup clears it and a new timeout starts. Only after the specified delay of inactivity does the debounced value update.
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = React.useState(() => {
try {
// In a browser: const item = window.localStorage.getItem(key);
// return item ? JSON.parse(item) : initialValue;
return initialValue; // Simplified for exercise environment
} catch (error) {
return initialValue;
}
});
const setValue = React.useCallback((value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
// In a browser: window.localStorage.setItem(key, JSON.stringify(valueToStore));
console.log('Saved to localStorage[' + key + ']:', JSON.stringify(valueToStore));
}, [key, storedValue]);
return [storedValue, setValue];
}
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 14);
console.log('Theme:', theme);
console.log('Font size:', fontSize);
setTheme('dark');
setFontSize(16);
return null;
}
Settings();
useLocalStorage wraps useState with localStorage synchronization. It reads from localStorage on mount and writes on every update. The lazy initializer in useState ensures localStorage is only read once.
function useCounter(initial) {
const [count, setCount] = React.useState(initial);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
function useLogger(label, value) {
React.useEffect(() => {
console.log('[' + label + '] value changed to:', value);
}, [label, value]);
}
function useLoggedCounter(initial, label) {
const counter = useCounter(initial);
useLogger(label, counter.count);
return counter;
}
function App() {
const { count } = useLoggedCounter(0, 'PageViews');
console.log('Page views:', count);
return null;
}
App();
useLoggedCounter composes useCounter and useLogger. Each hook handles one concern: useCounter manages the count state, useLogger handles logging, and useLoggedCounter combines them. This is the power of hook composition.
hook composition, useDebounce, useLocalStorage, combining hooks, advanced patterns