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
- 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.
-
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,
};
}
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>
);
}
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;
}
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];
}
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>
);
}
Best Practices for Custom Hooks
-
Return Consistent Types: Always return an array (if order matters for destructuring like
useState) or an object (if property names matter for destructuring likeuseForm). - 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.
-
Use Memoization Wisely: Leverage
useCallbackanduseMemoinside 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)