Custom React Hooks: State Management and Side Effects
This challenge focuses on creating custom React hooks in TypeScript to encapsulate reusable state management logic and side effects. By building your own hooks, you'll gain a deeper understanding of React's hook system and how to abstract complex component behavior into clean, maintainable functions. This is crucial for building scalable and efficient React applications.
Problem Description
Your task is to implement two custom React hooks in TypeScript:
-
usePersistentState: This hook should manage a piece of state that persists across component re-renders and also persists inlocalStoragewhen the browser is closed and reopened. It should behave similarly touseStatebut with the added persistence functionality. -
useDebouncedEffect: This hook should allow you to run a side effect (similar touseEffect) but with a debouncing mechanism. The effect should only execute after a specified delay has passed since the last time its dependencies changed. This is useful for preventing excessive API calls or other costly operations that might be triggered by rapid user input.
Key Requirements
usePersistentState Hook:
- Accepts an initial value and a
localStoragekey. - Returns a tuple containing the current state value and a function to update it, just like
useState. - When initialized, it should attempt to read the value from
localStorageusing the provided key. If no value is found inlocalStorage, it should use the provided initial value. - Whenever the state is updated, the new value should be saved to
localStorageunder the provided key. - It should handle potential errors during
localStorageoperations gracefully (e.g., iflocalStorageis unavailable or throws an error).
useDebouncedEffect Hook:
- Accepts a callback function (the effect to run), an array of dependencies, and a debounce delay in milliseconds.
- The callback function should be executed only after the debounce delay has passed since the last dependency change.
- If dependencies change before the delay, the previous timer should be cleared, and a new timer should start.
- The callback function should be typed to accept no arguments.
Expected Behavior
-
usePersistentState:- Component mounts -> reads from
localStorageor uses initial value. - State updates -> updates local state and saves to
localStorage. - Page refresh ->
localStoragevalue is loaded. localStorageunavailable -> behaves likeuseState.
- Component mounts -> reads from
-
useDebouncedEffect:- Component mounts -> effect runs after delay if dependencies are stable initially.
- Dependencies change -> timer resets.
- Dependencies change rapidly -> effect only runs after the last change and the delay.
- Dependencies remain stable -> effect runs after the initial delay (or not at all if dependencies haven't changed since last execution and no delay).
Important Edge Cases
usePersistentState:localStorageis disabled or inaccessible (e.g., in incognito mode, server-side rendering).- Data stored in
localStorageis malformed (e.g., not valid JSON). - The
localStoragekey is empty or invalid.
useDebouncedEffect:- The initial render with no dependency changes.
- The debounce delay is 0.
- Dependencies change on every render.
Examples
Example 1: usePersistentState
Component Code:
import React from 'react';
import { usePersistentState } from './hooks'; // Assuming your hooks are in './hooks'
function Counter() {
const [count, setCount] = usePersistentState<number>(0, 'myAppCounter');
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
Scenario:
- User opens the app for the first time.
localStoragehas no keymyAppCounter. - The component renders,
countis0. - User clicks "Increment" twice.
countbecomes2. - User refreshes the page.
- User opens the app again.
Expected Output (in the UI):
After step 3, the UI will show "Count: 2".
After step 5, the UI will show "Count: 2" because the value was persisted in localStorage.
Explanation:
The usePersistentState hook correctly initializes count to 0, updates it on button clicks, and saves the value 2 to localStorage. Upon refresh, it reads 2 from localStorage, re-initializing count to 2.
Example 2: useDebouncedEffect
Component Code:
import React, { useState, useEffect } from 'react';
import { useDebouncedEffect } from './hooks';
function SearchInput() {
const [query, setQuery] = useState('');
const [apiResult, setApiResult] = useState<string>('No search yet');
// Simulate an API call that takes time
const performSearch = (searchTerm: string) => {
console.log(`Performing search for: ${searchTerm}`);
// In a real app, this would be an API call
setTimeout(() => {
setApiResult(`Results for "${searchTerm}"`);
}, 500); // Simulate API latency
};
useDebouncedEffect(
() => {
if (query.length > 0) {
performSearch(query);
} else {
setApiResult('No search yet');
}
},
[query], // Dependency is the search query
500 // Debounce delay of 500ms
);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<p>API Result: {apiResult}</p>
</div>
);
}
Scenario:
- User types "a".
- User types "ap".
- User types "app".
- User pauses typing for 1 second.
Expected Output (Console Logs and UI):
- "Performing search for: a" (might appear and be cancelled)
- "Performing search for: ap" (might appear and be cancelled)
- "Performing search for: app" (this will be the one that actually executes after the delay)
- UI will update to "API Result: Results for "app"" approximately 1 second after the initial typing started (or 500ms after "app" was fully typed and the pause began).
Explanation:
Even though the query state changes rapidly, the useDebouncedEffect hook ensures that performSearch is only called once after the user stops typing for 500ms. If the user types "app" and then waits for more than 500ms, the search will be performed. If they continue typing, the timer resets.
Constraints
usePersistentState:localStoragekey should be a non-empty string.localStorageoperations should be wrapped intry...catchblocks to handle potential errors.
useDebouncedEffect:- Debounce delay will be a non-negative integer.
- The effect callback should be typed to accept no arguments.
- Ensure cleanup of the timer when the component unmounts or dependencies change.
- General:
- All implementations must be in TypeScript.
- The solution should be efficient and avoid unnecessary re-renders.
- The hooks should be generic where appropriate (e.g.,
usePersistentState<T>).
Notes
- For
usePersistentState, consider how you will handle serialization and deserialization of state values if they are not simple primitives (e.g., objects, arrays).JSON.stringifyandJSON.parseare your friends here. - For
useDebouncedEffect, think about howsetTimeoutandclearTimeoutwork together to achieve the debouncing behavior. - Remember that custom hooks must start with the
useprefix. - Consider the return type of
usePersistentState– it should mirroruseState's return type. - Pay close attention to the dependency array for
useDebouncedEffect. Incorrect dependencies can lead to unexpected behavior. - For
usePersistentState, you might want to investigate thewindow.addEventListener('storage', ...)event for more advanced scenarios, though for this challenge, directly reading/writing on state change is sufficient. - Be mindful of server-side rendering (SSR) where
localStorageis not available. YourusePersistentStatehook should handle this gracefully.