Hone logo
Hone
Problems

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:

  1. usePersistentState: This hook should manage a piece of state that persists across component re-renders and also persists in localStorage when the browser is closed and reopened. It should behave similarly to useState but with the added persistence functionality.

  2. useDebouncedEffect: This hook should allow you to run a side effect (similar to useEffect) 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 localStorage key.
  • 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 localStorage using the provided key. If no value is found in localStorage, it should use the provided initial value.
  • Whenever the state is updated, the new value should be saved to localStorage under the provided key.
  • It should handle potential errors during localStorage operations gracefully (e.g., if localStorage is 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 localStorage or uses initial value.
    • State updates -> updates local state and saves to localStorage.
    • Page refresh -> localStorage value is loaded.
    • localStorage unavailable -> behaves like useState.
  • 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:
    • localStorage is disabled or inaccessible (e.g., in incognito mode, server-side rendering).
    • Data stored in localStorage is malformed (e.g., not valid JSON).
    • The localStorage key 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:

  1. User opens the app for the first time. localStorage has no key myAppCounter.
  2. The component renders, count is 0.
  3. User clicks "Increment" twice. count becomes 2.
  4. User refreshes the page.
  5. 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:

  1. User types "a".
  2. User types "ap".
  3. User types "app".
  4. 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:
    • localStorage key should be a non-empty string.
    • localStorage operations should be wrapped in try...catch blocks 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.stringify and JSON.parse are your friends here.
  • For useDebouncedEffect, think about how setTimeout and clearTimeout work together to achieve the debouncing behavior.
  • Remember that custom hooks must start with the use prefix.
  • Consider the return type of usePersistentState – it should mirror useState'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 the window.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 localStorage is not available. Your usePersistentState hook should handle this gracefully.
Loading editor...
typescript