DEV Community

Cover image for Building Reusable Custom React Hooks: Form State, Debounce, and Intersection Observers
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Building Reusable Custom React Hooks: Form State, Debounce, and Intersection Observers

Building Reusable Custom React Hooks: Form State, Debounce, and Intersection Observers

title: "Building Reusable Custom React Hooks: Form State, Debounce, and Intersection Observers"
published: true
published_at: "2026-11-17T09:00:00+05:30"
description: "Learn how to extract complex business logic from UI components by building production-ready custom React hooks. We explore the Rules of Hooks, cleanup handling in useEffect, and construct robust useForm, useDebounce, and useIntersectionObserver hooks."
tags: [react, javascript, cleancode, webdev]
ai_disclosure_level: some_ai

Introduction

React hooks revolutionized how we compose state and side-effects in component hierarchies. However, as applications scale, UI components often bloat with cross-cutting concerns like form validation, asynchronous API debouncing, and DOM element visibility tracking.

Custom hooks allow developers to extract this complex business logic into pure, testable, and highly reusable JavaScript functions. In this article, we will examine the core mechanics of custom hooks, adhere strictly to the Rules of Hooks, and build three production-grade hooks: useForm, useDebounce, and useIntersectionObserver.

The Rules of Hooks and Separation of Concerns

Before writing custom hooks, we must establish a solid grasp of why and how hooks function under the hood. React relies on the order in which hooks are called on every render pass to associate state with the correct component instance.

Core Rules

  1. Only Call Hooks at the Top Level: Never call hooks inside loops, conditions, or nested functions. This ensures hooks are called in the exact same order on every component render.
  2. Only Call Hooks from React Functions: Call hooks from React functional components or custom hooks (which must start with the prefix use).

By encapsulating stateful logic inside a custom hook, our UI components focus strictly on rendering markup and responding to user gestures, achieving a clean separation of concerns.

1. Building a Robust Form State Hook (useForm)

Forms are notoriously verbose in React. Managing value states, change handlers, validation rules, and submission states across dozens of forms leads to massive code duplication. Let us build a generalized useForm hook that handles state management and change tracking.

import { useState, useCallback } from 'react';

export function useForm(initialValues = {}) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const fieldValue = type === 'checkbox' ? checked : value;

    setValues((prevValues) => ({
      ...prevValues,
      [name]: fieldValue,
    }));
  }, []);

  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    isSubmitting,
    setIsSubmitting,
    setErrors,
    handleChange,
    resetForm,
    setValues,
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage in a Component

import React from 'react';
import { useForm } from './useForm';

export function LoginForm() {
  const { values, handleChange, resetForm } = useForm({
    email: '',
    password: '',
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitting payload:', values);
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
        />
      </div>
      <div>
        <label>Password:</label>
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
        />
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Managing Side-Effects and Cleanup: useDebounce

When handling high-frequency events such as keystrokes in a search input or window resize events, executing callbacks immediately degrades application performance. A useDebounce hook delays invoking a value update until a specified delay has elapsed without new changes.

import { useState, useEffect } from 'react';

export function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Set up a timeout to update the debounced value after the specified delay
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup function clears the timeout if value or delay changes, or if the component unmounts
    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}
Enter fullscreen mode Exit fullscreen mode

Why the Cleanup Function Matters

Without clearTimeout(timer) returned inside the useEffect callback, previous timers would continue executing in the background, leading to memory leaks and erratic state updates. Always clean up timers, subscriptions, and event listeners when building asynchronous hooks.

3. DOM Interactions: useIntersectionObserver

Performance optimization often relies on lazy-loading resources or triggering animations when elements scroll into the viewport. The Intersection Observer API provides an asynchronous way to observe changes in the intersection of a target element with an ancestor element or top-level viewport.

import { useState, useEffect, useRef } from 'react';

export function useIntersectionObserver(options = {}) {
  const elementRef = useRef(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const node = elementRef.current;
    if (!node) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsVisible(entry.isIntersecting);
    }, options);

    observer.observe(node);

    return () => {
      if (node) {
        observer.unobserve(node);
      }
      observer.disconnect();
    };
  }, [options]);

  return [elementRef, isVisible];
}
Enter fullscreen mode Exit fullscreen mode

Implementing Infinite Scroll or Lazy Animations

import React from 'react';
import { useIntersectionObserver } from './useIntersectionObserver';

export function LazyImageComponent({ src, alt }) {
  const [ref, isVisible] = useIntersectionObserver({
    threshold: 0.1,
    freezeOnceVisible: true,
  });

  return (
    <div ref={ref} style={{ minHeight: '300px', background: '#eee' }}>
      {isVisible ? (
        <img src={src} alt={alt} style={{ width: '100%' }} />
      ) : (
        <p>Loading placeholder...</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Best Practices for Custom Hooks

  1. Return Consistent Types: Always return an array (if order matters for destructuring like useState) or an object (if property names matter for destructuring like useForm).
  2. Keep Hooks Focused: Avoid creating monolithic "god-hooks" that manage forms, data fetching, and local storage simultaneously. Composition is key—combine smaller hooks inside larger domain-specific hooks.
  3. Use Memoization Wisely: Leverage useCallback and useMemo inside custom hooks only when returning functions or objects to child components that depend heavily on reference equality.

Conclusion

Custom React hooks bridge the gap between clean architecture and component reusability. By extracting form state management, debouncing logic, and browser API wrappers like Intersection Observers into dedicated functions, we improve testability, maintainability, and code readability across our codebase.

Top comments (0)