Creating Custom Hooks

Building reusable logic with React's custom hooks pattern

Introduction to Custom Hooks

React's built-in Hooks like useState, useEffect, and useReducer provide powerful ways to add state and side effects to functional components. However, as applications grow in complexity, you'll often find yourself reusing the same patterns of stateful logic across multiple components.

Custom Hooks are a feature of React that allow you to extract component logic into reusable functions. They're a way to share stateful logic between components without changing their structure.

Real-world analogy: Think of custom hooks like recipes in a cookbook. Instead of repeating the same cooking steps (stateful logic) in every dish (component) you prepare, you can follow a standardized recipe (custom hook) that ensures consistent results and saves you time.

flowchart TD A[Component] --> B[JSX/UI] A --> C[Logic] C --> D[State Management] C --> E[Side Effects] C --> F[Context] C --> G[Other Logic] H[Custom Hooks] -.-> D H -.-> E H -.-> F H -.-> G style H fill:#bbf,stroke:#33a,stroke-width:2px

Why Use Custom Hooks?

Custom Hook Rules and Conventions

When creating custom hooks, follow these important rules and conventions:

Naming Convention

Custom hooks must start with "use" (e.g., useFormInput, useFetch). This naming convention is critical because:

Hooks Rules Still Apply

Custom hooks must follow the same rules as built-in hooks:

Return Values

Custom hooks can return anything:

Hook Composition

Custom hooks can use other hooks, both built-in and custom:


function useWindowSize() {
  // Using built-in hooks
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    // ...
  }, []);
  
  return size;
}

function useResponsiveLayout() {
  // Using another custom hook
  const windowSize = useWindowSize();
  
  // Additional logic...
  const isMobile = windowSize.width < 768;
  
  return { ...windowSize, isMobile };
}
      

Building Your First Custom Hook

Let's start with a simple example: a useToggle hook that manages a boolean state.

The Problem

Setting up toggle state is a common pattern:


// Without custom hook
function Accordion() {
  const [isOpen, setIsOpen] = useState(false);
  
  const toggle = () => {
    setIsOpen(prevIsOpen => !prevIsOpen);
  };
  
  return (
    <div>
      <button onClick={toggle}>
        {isOpen ? 'Close' : 'Open'} Accordion
      </button>
      {isOpen && (
        <div className="accordion-content">
          Content goes here...
        </div>
      )}
    </div>
  );
}
      

The Solution: useToggle Hook


// Custom hook definition
function useToggle(initialState = false) {
  const [state, setState] = useState(initialState);
  
  // Define and memoize toggler function
  const toggle = useCallback(() => {
    setState(state => !state);
  }, []);
  
  return [state, toggle];
}

// Using the custom hook
function Accordion() {
  const [isOpen, toggle] = useToggle(false);
  
  return (
    <div>
      <button onClick={toggle}>
        {isOpen ? 'Close' : 'Open'} Accordion
      </button>
      {isOpen && (
        <div className="accordion-content">
          Content goes here...
        </div>
      )}
    </div>
  );
}
      

Benefits of this approach:

Enhanced Version

We can enhance the useToggle hook to provide more functionality:


function useToggle(initialState = false) {
  const [state, setState] = useState(initialState);
  
  const toggle = useCallback(() => {
    setState(state => !state);
  }, []);
  
  const setTrue = useCallback(() => {
    setState(true);
  }, []);
  
  const setFalse = useCallback(() => {
    setState(false);
  }, []);
  
  // Return state and all functions
  return [state, toggle, setTrue, setFalse];
}

function Modal() {
  const [isOpen, toggle, openModal, closeModal] = useToggle(false);
  
  return (
    <div>
      <button onClick={openModal}>Open Modal</button>
      
      {isOpen && (
        <div className="modal">
          <div className="modal-content">
            <h2>Modal Title</h2>
            <p>Modal content goes here...</p>
            <button onClick={closeModal}>Close</button>
          </div>
        </div>
      )}
    </div>
  );
}
      

Custom Hooks for Form Handling

Forms are a perfect candidate for custom hooks because they involve repetitive logic for managing input values, validation, and submission.

1. Basic Form Input Hook


function useInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);
  
  const handleChange = useCallback((e) => {
    setValue(e.target.value);
  }, []);
  
  const reset = useCallback(() => {
    setValue(initialValue);
  }, [initialValue]);
  
  // Return everything needed by the input
  return {
    value,
    onChange: handleChange,
    reset,
    // Add useful props to make it easier to spread onto inputs
    inputProps: {
      value,
      onChange: handleChange
    }
  };
}

// Usage example
function SimpleForm() {
  const name = useInput('');
  const email = useInput('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitted:', { name: name.value, email: email.value });
    name.reset();
    email.reset();
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name:</label>
        <input type="text" {...name.inputProps} />
      </div>
      
      <div>
        <label>Email:</label>
        <input type="email" {...email.inputProps} />
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}
      

2. Form Validation Hook


function useFormValidation(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  // Update field value
  const handleChange = useCallback((e) => {
    const { name, value } = e.target;
    setValues(prevValues => ({
      ...prevValues,
      [name]: value
    }));
  }, []);
  
  // Mark field as touched on blur
  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prevTouched => ({
      ...prevTouched,
      [name]: true
    }));
  }, []);
  
  // Validate all fields
  const validateForm = useCallback(() => {
    const validationErrors = validate(values);
    setErrors(validationErrors);
    return Object.keys(validationErrors).length === 0;
  }, [values, validate]);
  
  // Reset form
  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);
  
  // Handle form submission
  const handleSubmit = useCallback(async (e, onSubmit) => {
    e.preventDefault();
    setTouched(
      Object.keys(values).reduce((touched, field) => {
        touched[field] = true;
        return touched;
      }, {})
    );
    
    const isValid = validateForm();
    
    if (isValid) {
      setIsSubmitting(true);
      try {
        await onSubmit(values);
        reset();
      } catch (error) {
        console.error('Submission error:', error);
      } finally {
        setIsSubmitting(false);
      }
    }
  }, [values, validateForm, reset]);
  
  // Validate on touched fields change
  useEffect(() => {
    if (Object.keys(touched).length > 0) {
      validateForm();
    }
  }, [touched, validateForm]);
  
  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  };
}

// Usage example
function SignupForm() {
  // Validation function
  const validate = (values) => {
    const errors = {};
    
    if (!values.username) {
      errors.username = 'Username is required';
    } else if (values.username.length < 3) {
      errors.username = 'Username must be at least 3 characters';
    }
    
    if (!values.email) {
      errors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(values.email)) {
      errors.email = 'Email address is invalid';
    }
    
    if (!values.password) {
      errors.password = 'Password is required';
    } else if (values.password.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }
    
    return errors;
  };
  
  const {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  } = useFormValidation(
    { username: '', email: '', password: '' }, 
    validate
  );
  
  const onSubmit = async (values) => {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    console.log('Form submitted successfully:', values);
    alert('Signup successful!');
  };
  
  return (
    <form onSubmit={(e) => handleSubmit(e, onSubmit)}>
      <div className="form-group">
        <label htmlFor="username">Username:</label>
        <input
          id="username"
          name="username"
          type="text"
          value={values.username}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.username && errors.username ? 'error' : ''}
        />
        {touched.username && errors.username && (
          <div className="error-message">{errors.username}</div>
        )}
      </div>
      
      <div className="form-group">
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          name="email"
          type="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.email && errors.email ? 'error' : ''}
        />
        {touched.email && errors.email && (
          <div className="error-message">{errors.email}</div>
        )}
      </div>
      
      <div className="form-group">
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          name="password"
          type="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.password && errors.password ? 'error' : ''}
        />
        {touched.password && errors.password && (
          <div className="error-message">{errors.password}</div>
        )}
      </div>
      
      <div className="form-actions">
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Signing Up...' : 'Sign Up'}
        </button>
        <button type="button" onClick={reset}>Reset</button>
      </div>
    </form>
  );
}
      

Custom Hooks for Data Fetching

Data fetching is another common pattern that can benefit from custom hooks.

Basic Data Fetching Hook


function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // Reset state when URL changes
    setLoading(true);
    setError(null);
    setData(null);
    
    let isMounted = true;
    
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }
        
        const result = await response.json();
        
        if (isMounted) {
          setData(result);
        }
      } catch (error) {
        if (isMounted) {
          setError(error.message || 'Something went wrong');
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    // Cleanup function
    return () => {
      isMounted = false;
    };
  }, [url]);
  
  return { data, loading, error };
}

// Usage example
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(
    `https://api.example.com/users/${userId}`
  );
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Website: {user.website}</p>
    </div>
  );
}
      

Advanced Data Fetching Hook with Cache and Refresh


function useDataApi(initialUrl, initialData) {
  const [url, setUrl] = useState(initialUrl);
  const [state, dispatch] = useReducer(dataFetchReducer, {
    data: initialData,
    loading: true,
    error: null,
    timestamp: null
  });
  
  // Cache for storing previous results
  const cache = useRef({});
  
  // Define reducer function
  function dataFetchReducer(state, action) {
    switch (action.type) {
      case 'FETCH_INIT':
        return {
          ...state,
          loading: true,
          error: null
        };
      case 'FETCH_SUCCESS':
        return {
          ...state,
          loading: false,
          data: action.payload,
          error: null,
          timestamp: new Date()
        };
      case 'FETCH_FAILURE':
        return {
          ...state,
          loading: false,
          error: action.payload
        };
      default:
        throw new Error(`Unsupported action type: ${action.type}`);
    }
  }
  
  // Function to fetch data
  const fetchData = useCallback(async (url, ignoreCache = false) => {
    dispatch({ type: 'FETCH_INIT' });
    
    // Check cache first if not ignoring cache
    if (!ignoreCache && cache.current[url]) {
      dispatch({ type: 'FETCH_SUCCESS', payload: cache.current[url] });
      return;
    }
    
    try {
      const response = await fetch(url);
      
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      
      const data = await response.json();
      
      // Store in cache
      cache.current[url] = data;
      
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_FAILURE', payload: error.message });
    }
  }, []);
  
  // Fetch data when URL changes
  useEffect(() => {
    fetchData(url);
  }, [url, fetchData]);
  
  // Function to refresh data
  const refresh = useCallback(() => {
    fetchData(url, true);
  }, [url, fetchData]);
  
  // Function to change URL and fetch new data
  const setUrlAndFetch = useCallback((newUrl) => {
    setUrl(newUrl);
  }, []);
  
  return {
    ...state,
    refresh,
    setUrl: setUrlAndFetch
  };
}

// Usage example
function PostList() {
  const [postId, setPostId] = useState(1);
  
  const {
    data,
    loading,
    error,
    timestamp,
    refresh,
    setUrl
  } = useDataApi(
    `https://jsonplaceholder.typicode.com/posts/${postId}`,
    {}
  );
  
  const handleNextPost = () => {
    setPostId(prevId => prevId + 1);
    setUrl(`https://jsonplaceholder.typicode.com/posts/${postId + 1}`);
  };
  
  return (
    <div>
      {loading ? (
        <div>Loading...</div>
      ) : error ? (
        <div>Error: {error}</div>
      ) : (
        <div>
          <h2>{data.title}</h2>
          <p>{data.body}</p>
          {timestamp && (
            <small>Last fetched: {timestamp.toLocaleTimeString()}</small>
          )}
        </div>
      )}
      
      <div className="controls">
        <button onClick={handleNextPost}>Next Post</button>
        <button onClick={refresh}>Refresh</button>
      </div>
    </div>
  );
}
      

Custom Hooks for Browser APIs

Custom hooks are perfect for encapsulating browser APIs and making them reactive.

1. Window Size Hook


function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    // Handler to call on window resize
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }
    
    // Add event listener
    window.addEventListener('resize', handleResize);
    
    // Call handler right away to update initial size
    handleResize();
    
    // Remove event listener on cleanup
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Empty array ensures effect runs only on mount and unmount
  
  return windowSize;
}

// Usage example
function ResponsiveComponent() {
  const { width, height } = useWindowSize();
  const isMobile = width < 768;
  
  return (
    <div>
      <p>Window size: {width} x {height}</p>
      <p>You are viewing this on a {isMobile ? 'mobile' : 'desktop'} device.</p>
      
      {isMobile ? (
        <MobileLayout />
      ) : (
        <DesktopLayout />
      )}
    </div>
  );
}
      

2. Clipboard Hook


function useClipboard() {
  const [copiedText, setCopiedText] = useState(null);
  const [error, setError] = useState(null);
  
  const copy = useCallback(async (text) => {
    try {
      await navigator.clipboard.writeText(text);
      setCopiedText(text);
      setError(null);
      
      // Reset copied text after 2 seconds
      setTimeout(() => {
        setCopiedText(null);
      }, 2000);
      
      return true;
    } catch (err) {
      console.error('Failed to copy:', err);
      setError('Failed to copy text');
      return false;
    }
  }, []);
  
  return { copiedText, error, copy };
}

// Usage example
function CopyableText({ text }) {
  const { copiedText, copy } = useClipboard();
  
  const handleCopy = () => {
    copy(text);
  };
  
  return (
    <div className="copyable-text">
      <pre>{text}</pre>
      <button onClick={handleCopy}>
        {copiedText === text ? 'Copied!' : 'Copy'}
      </button>
    </div>
  );
}
      

3. Geolocation Hook


function useGeolocation(options = {}) {
  const [location, setLocation] = useState({
    loaded: false,
    coordinates: { lat: null, lng: null },
    error: null
  });
  
  useEffect(() => {
    if (!navigator.geolocation) {
      setLocation({
        loaded: true,
        error: {
          code: 0,
          message: 'Geolocation not supported'
        }
      });
      return;
    }
    
    const onSuccess = (position) => {
      const { latitude, longitude } = position.coords;
      
      setLocation({
        loaded: true,
        coordinates: {
          lat: latitude,
          lng: longitude
        },
        error: null
      });
    };
    
    const onError = (error) => {
      setLocation({
        loaded: true,
        coordinates: {
          lat: null,
          lng: null
        },
        error
      });
    };
    
    navigator.geolocation.getCurrentPosition(
      onSuccess,
      onError,
      options
    );
    
    // If you need continuous updates, you can use watchPosition
    // const watchId = navigator.geolocation.watchPosition(onSuccess, onError, options);
    // return () => navigator.geolocation.clearWatch(watchId);
  }, [options]);
  
  return location;
}

// Usage example
function LocationBasedComponent() {
  const { loaded, coordinates, error } = useGeolocation({
    enableHighAccuracy: true,
    maximumAge: 15000,
    timeout: 12000
  });
  
  if (!loaded) return <div>Getting your location...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <h2>Your Location</h2>
      <p>Latitude: {coordinates.lat}</p>
      <p>Longitude: {coordinates.lng}</p>
      <a 
        href={`https://maps.google.com/?q=${coordinates.lat},${coordinates.lng}`}
        target="_blank"
        rel="noopener noreferrer"
      >
        View on Google Maps
      </a>
    </div>
  );
}
      

Custom Hooks for UI Interactions

These hooks can help manage common UI interaction patterns.

1. Hover Hook


function useHover() {
  const [isHovering, setIsHovering] = useState(false);
  const ref = useRef(null);
  
  const handleMouseOver = useCallback(() => setIsHovering(true), []);
  const handleMouseOut = useCallback(() => setIsHovering(false), []);
  
  useEffect(() => {
    const node = ref.current;
    if (node) {
      node.addEventListener('mouseover', handleMouseOver);
      node.addEventListener('mouseout', handleMouseOut);
      
      return () => {
        node.removeEventListener('mouseover', handleMouseOver);
        node.removeEventListener('mouseout', handleMouseOut);
      };
    }
  }, [handleMouseOver, handleMouseOut]);
  
  // Return both the hover state and the ref to be attached
  return [ref, isHovering];
}

// Usage example
function HoverCard() {
  const [hoverRef, isHovering] = useHover();
  
  return (
    <div 
      ref={hoverRef}
      className={`card ${isHovering ? 'card-hover' : ''}`}
    >
      <h3>Hover Me</h3>
      {isHovering && (
        <div className="card-tooltip">
          This card is being hovered!
        </div>
      )}
    </div>
  );
}
      

2. Drag and Drop Hook


function useDrag(initialPosition = { x: 0, y: 0 }) {
  const [position, setPosition] = useState(initialPosition);
  const [isDragging, setIsDragging] = useState(false);
  const elementRef = useRef(null);
  
  // Store the starting position and offset
  const dragInfo = useRef({
    startX: 0,
    startY: 0,
    initialX: 0,
    initialY: 0
  });
  
  const handleMouseDown = useCallback((e) => {
    if (!elementRef.current) return;
    
    // Capture starting position
    dragInfo.current.startX = e.clientX;
    dragInfo.current.startY = e.clientY;
    dragInfo.current.initialX = position.x;
    dragInfo.current.initialY = position.y;
    
    setIsDragging(true);
    
    // Prevent text selection during drag
    document.body.style.userSelect = 'none';
  }, [position]);
  
  const handleMouseMove = useCallback((e) => {
    if (!isDragging) return;
    
    // Calculate new position
    const dx = e.clientX - dragInfo.current.startX;
    const dy = e.clientY - dragInfo.current.startY;
    
    setPosition({
      x: dragInfo.current.initialX + dx,
      y: dragInfo.current.initialY + dy
    });
  }, [isDragging]);
  
  const handleMouseUp = useCallback(() => {
    setIsDragging(false);
    document.body.style.userSelect = '';
  }, []);
  
  useEffect(() => {
    // Add event listeners when dragging starts
    if (isDragging) {
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    }
    
    // Clean up event listeners
    return () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [isDragging, handleMouseMove, handleMouseUp]);
  
  return {
    position,
    isDragging,
    elementRef,
    handleMouseDown
  };
}

// Usage example
function DraggableBox() {
  const { position, isDragging, elementRef, handleMouseDown } = useDrag();
  
  return (
    <div
      ref={elementRef}
      style={{
        width: '100px',
        height: '100px',
        backgroundColor: isDragging ? '#3498db' : '#e74c3c',
        position: 'absolute',
        left: `${position.x}px`,
        top: `${position.y}px`,
        cursor: isDragging ? 'grabbing' : 'grab',
        borderRadius: '8px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        color: 'white',
        userSelect: 'none'
      }}
      onMouseDown={handleMouseDown}
    >
      Drag Me
    </div>
  );
}
      

3. Infinite Scroll Hook


function useInfiniteScroll(callback, options = {}) {
  const { threshold = 100, initialLoad = true } = options;
  
  const [isFetching, setIsFetching] = useState(initialLoad);
  const [isEnd, setIsEnd] = useState(false);
  
  // Function to call when bottom is reached
  const fetchMoreItems = useCallback(() => {
    if (!isFetching && !isEnd) {
      setIsFetching(true);
    }
  }, [isFetching, isEnd]);
  
  // Check if we've reached the bottom
  const handleScroll = useCallback(() => {
    if (isEnd) return;
    
    const scrollTop = window.scrollY || document.documentElement.scrollTop;
    const scrollHeight = document.documentElement.scrollHeight;
    const clientHeight = document.documentElement.clientHeight;
    
    // If we're near the bottom, fetch more items
    if (scrollHeight - scrollTop - clientHeight < threshold) {
      fetchMoreItems();
    }
  }, [fetchMoreItems, isEnd, threshold]);
  
  // Call the callback when isFetching changes
  useEffect(() => {
    if (!isFetching) return;
    
    const fetchData = async () => {
      try {
        // Call the callback and get result
        const hasMore = await callback();
        
        // If there's no more data, set isEnd to true
        if (hasMore === false) {
          setIsEnd(true);
        }
      } catch (error) {
        console.error('Error fetching more items:', error);
      } finally {
        setIsFetching(false);
      }
    };
    
    fetchData();
  }, [callback, isFetching]);
  
  // Add scroll event listener
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    
    // Initial load if required
    if (initialLoad) {
      fetchMoreItems();
    }
    
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll, fetchMoreItems, initialLoad]);
  
  return { isFetching, isEnd, setIsEnd };
}

// Usage example
function InfinitePostList() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  
  const loadMorePosts = useCallback(async () => {
    try {
      // Simulate API call
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
      );
      const newPosts = await response.json();
      
      if (newPosts.length === 0) {
        return false; // No more posts
      }
      
      setPosts(prevPosts => [...prevPosts, ...newPosts]);
      setPage(prevPage => prevPage + 1);
      
      return true; // More posts might be available
    } catch (error) {
      console.error('Error loading posts:', error);
      return false;
    }
  }, [page]);
  
  const { isFetching, isEnd } = useInfiniteScroll(loadMorePosts);
  
  return (
    <div className="post-list">
      <h2>Posts</h2>
      
      {posts.map(post => (
        <div key={post.id} className="post">
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </div>
      ))}
      
      {isFetching && <div className="loading">Loading more posts...</div>}
      {isEnd && <div className="end-message">No more posts to load</div>}
    </div>
  );
}
      

Composing Custom Hooks

One of the most powerful features of custom hooks is composition - you can build complex hooks by combining simpler ones.

Building a Complex Hook: useLocalStorageState


// First, create a hook for localStorage interaction
function useLocalStorage(key, initialValue) {
  // Get stored value from localStorage
  const getStoredValue = () => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  };
  
  // Store a reference to the key
  const keyRef = useRef(key);
  
  // State to store the value
  const [storedValue, setStoredValue] = useState(getStoredValue);
  
  // Update keyRef if key changes
  useEffect(() => {
    keyRef.current = key;
  }, [key]);
  
  // Function to update stored value
  const setValue = useCallback((value) => {
    try {
      // Allow value to be a function
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      
      // Save state
      setStoredValue(valueToStore);
      
      // Save to localStorage
      window.localStorage.setItem(keyRef.current, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${keyRef.current}":`, error);
    }
  }, [storedValue]);
  
  // Listen for changes in other tabs/windows
  useEffect(() => {
    function handleStorageChange(event) {
      if (event.key === keyRef.current) {
        setStoredValue(getStoredValue());
      }
    }
    
    // Listen for storage event
    window.addEventListener('storage', handleStorageChange);
    
    return () => {
      window.removeEventListener('storage', handleStorageChange);
    };
  }, []);
  
  return [storedValue, setValue];
}

// Now use the above hook to create a theme hook
function useTheme() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  
  const toggleTheme = useCallback(() => {
    setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
  }, [setTheme]);
  
  // Apply theme to document when it changes
  useEffect(() => {
    document.body.setAttribute('data-theme', theme);
  }, [theme]);
  
  return { theme, setTheme, toggleTheme, isDarkTheme: theme === 'dark' };
}

// Create a hook for user settings that uses the theme hook
function useUserSettings() {
  const { theme, setTheme, toggleTheme, isDarkTheme } = useTheme();
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 'medium');
  const [notifications, setNotifications] = useLocalStorage('notifications', true);
  
  // Create preset configurations
  const applyPreset = useCallback((preset) => {
    switch (preset) {
      case 'reading':
        setTheme('light');
        setFontSize('large');
        break;
      case 'night':
        setTheme('dark');
        setFontSize('medium');
        break;
      case 'default':
      default:
        setTheme('light');
        setFontSize('medium');
        break;
    }
  }, [setTheme, setFontSize]);
  
  return {
    theme,
    setTheme,
    toggleTheme,
    isDarkTheme,
    fontSize,
    setFontSize,
    notifications,
    setNotifications,
    applyPreset
  };
}

// Usage example
function UserSettingsPanel() {
  const {
    theme,
    toggleTheme,
    fontSize,
    setFontSize,
    notifications,
    setNotifications,
    applyPreset
  } = useUserSettings();
  
  return (
    <div className="settings-panel">
      <h2>User Settings</h2>
      
      <div className="setting-group">
        <h3>Theme</h3>
        <button onClick={toggleTheme}>
          {theme === 'light' ? 'Switch to Dark Mode' : 'Switch to Light Mode'}
        </button>
      </div>
      
      <div className="setting-group">
        <h3>Font Size</h3>
        <select 
          value={fontSize} 
          onChange={(e) => setFontSize(e.target.value)}
        >
          <option value="small">Small</option>
          <option value="medium">Medium</option>
          <option value="large">Large</option>
        </select>
      </div>
      
      <div className="setting-group">
        <h3>Notifications</h3>
        <label>
          <input 
            type="checkbox"
            checked={notifications}
            onChange={(e) => setNotifications(e.target.checked)}
          />
          Enable Notifications
        </label>
      </div>
      
      <div className="setting-presets">
        <h3>Presets</h3>
        <button onClick={() => applyPreset('default')}>Default</button>
        <button onClick={() => applyPreset('reading')}>Reading Mode</button>
        <button onClick={() => applyPreset('night')}>Night Mode</button>
      </div>
    </div>
  );
}
      

Testing Custom Hooks

Testing custom hooks requires some special considerations since hooks can only be called inside function components.

Using React Testing Library's renderHook


// Example test for useCounter hook
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';

describe('useCounter', () => {
  test('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  test('should initialize with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  test('should increment counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('should decrement counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  test('should reset counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(0);
  });
  
  test('should reset counter to custom value', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.reset(10);
    });
    
    expect(result.current.count).toBe(10);
  });
  
  test('should update when dependencies change', () => {
    const { result, rerender } = renderHook(
      ({ initialValue }) => useCounter(initialValue),
      { initialProps: { initialValue: 0 } }
    );
    
    // Change the initial value prop
    rerender({ initialValue: 10 });
    
    // Check if hook updates with new initialValue
    expect(result.current.count).toBe(10);
  });
});
      

Creating a Test Component Wrapper

For testing hooks that use context or other React features, you might need to create a wrapper component:


// Testing a hook that uses context
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import useTheme from './useTheme';

// Test component that uses the hook
function TestComponent() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <div>
      <div data-testid="theme-value">{theme}</div>
      <button onClick={toggleTheme} data-testid="toggle-button">
        Toggle Theme
      </button>
    </div>
  );
}

describe('useTheme', () => {
  test('should have initial theme set to light', () => {
    render(
      <ThemeProvider>
        <TestComponent />
      </ThemeProvider>
    );
    
    expect(screen.getByTestId('theme-value').textContent).toBe('light');
  });
  
  test('should toggle theme when button is clicked', () => {
    render(
      <ThemeProvider>
        <TestComponent />
      </ThemeProvider>
    );
    
    // Initial state
    expect(screen.getByTestId('theme-value').textContent).toBe('light');
    
    // Click toggle button
    fireEvent.click(screen.getByTestId('toggle-button'));
    
    // Check updated state
    expect(screen.getByTestId('theme-value').textContent).toBe('dark');
  });
});
      

Remember to mock any external dependencies like browser APIs or fetch calls when testing hooks that use them.

Best Practices for Custom Hooks

Naming Conventions

Designing Hook APIs

Performance Considerations

Error Handling

Documentation

Practice Exercises

Exercise 1: Media Query Hook

Create a useMediaQuery hook that monitors a CSS media query and returns a boolean indicating whether it matches.


// Example usage:
function ResponsiveComponent() {
  const isDesktop = useMediaQuery('(min-width: 1024px)');
  const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
  const isMobile = useMediaQuery('(max-width: 767px)');
  
  return (
    <div>
      {isDesktop && <DesktopLayout />}
      {isTablet && <TabletLayout />}
      {isMobile && <MobileLayout />}
    </div>
  );
}
      

Exercise 2: Form Field Hook

Create a useFormField hook that handles validation, error messages, formatting, and other common form field behaviors.


// Example usage:
function RegistrationForm() {
  const username = useFormField('', {
    required: 'Username is required',
    minLength: { value: 3, message: 'Username must be at least 3 characters' },
    pattern: { 
      value: /^[A-Za-z0-9_]+$/, 
      message: 'Username can only contain letters, numbers and underscores' 
    }
  });
  
  const email = useFormField('', {
    required: 'Email is required',
    pattern: { 
      value: /\S+@\S+\.\S+/, 
      message: 'Email address is invalid' 
    }
  });
  
  // More form fields...
  
  return (
    <form>
      <div>
        <label htmlFor="username">Username:</label>
        <input id="username" {...username.props} />
        {username.error && (
          <span className="error">{username.error}</span>
        )}
      </div>
      
      <div>
        <label htmlFor="email">Email:</label>
        <input id="email" type="email" {...email.props} />
        {email.error && (
          <span className="error">{email.error}</span>
        )}
      </div>
      
      {/* More form fields... */}
    </form>
  );
}
      

Exercise 3: Combined Hooks Application

Create a small application that combines at least three different custom hooks. For example, a todo list app that uses:

Design each hook to be reusable and combine them effectively.

Summary

In this lecture, we've covered:

Custom hooks are a powerful pattern in React that allow you to extract and reuse stateful logic across components. By following the conventions and best practices we've discussed, you can create a library of hooks that will make your React applications more maintainable, readable, and efficient.

As you build more complex React applications, look for opportunities to abstract common patterns into custom hooks. This will not only reduce duplication in your codebase, but also make your components cleaner and more focused on their primary purpose of rendering UI.

Further Resources