Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
22 minLesson 11 of 33
Advanced React Patterns

Custom Hooks: Reusable Logic

Custom Hooks: Reusable Logic

Custom hooks are the best feature React added in recent years. They let you extract stateful logic from components into standalone functions — reusable, testable, and composable. Any time you see the same useState + useEffect pattern in two components, a custom hook is the right abstraction.

The Rule: It's Just a Function

A custom hook is a regular JavaScript function whose name starts with use and that calls other hooks inside it. That's it. No magic, no framework — just a naming convention that React uses to enforce the Rules of Hooks.

// This is a custom hook
function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });
    
    useEffect(() => {
        function handleResize() {
            setSize({ width: window.innerWidth, height: window.innerHeight });
        }
        
        window.addEventListener("resize", handleResize);
        return () => window.removeEventListener("resize", handleResize);
    }, []);
    
    return size;
}

// Usage — same logic, no duplication
function Header() {
    const { width } = useWindowSize();
    return <nav className={width < 768 ? "mobile-nav" : "desktop-nav"}>...</nav>;
}

function Chart() {
    const { width, height } = useWindowSize();
    return <canvas width={width} height={height} />;
}

useFetch — Data Fetching Hook

Stop writing the same loading/error/data pattern in every component:

function useFetch<T>(url: string) {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);
    
    useEffect(() => {
        let cancelled = false;
        
        setLoading(true);
        setError(null);
        
        fetch(url)
            .then(res => {
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                return res.json();
            })
            .then(data => {
                if (!cancelled) {
                    setData(data);
                    setLoading(false);
                }
            })
            .catch(err => {
                if (!cancelled) {
                    setError(err);
                    setLoading(false);
                }
            });
        
        return () => { cancelled = true; };
    }, [url]);
    
    return { data, loading, error };
}

// Clean component — no fetch logic visible
function CourseList() {
    const { data: courses, loading, error } = useFetch<Course[]>("/api/courses");
    
    if (loading) return <Spinner />;
    if (error) return <ErrorMessage message={error.message} />;
    if (!courses) return null;
    
    return <CourseGrid courses={courses} />;
}

useLocalStorage — Persistent State

function useLocalStorage<T>(key: string, initialValue: T) {
    const [stored, setStored] = useState<T>(() => {
        try {
            const item = localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch {
            return initialValue;
        }
    });
    
    function setValue(value: T | ((prev: T) => T)) {
        try {
            const valueToStore = value instanceof Function ? value(stored) : value;
            setStored(valueToStore);
            localStorage.setItem(key, JSON.stringify(valueToStore));
        } catch (err) {
            console.error(err);
        }
    }
    
    return [stored, setValue] as const;
}

// Works exactly like useState, but persists to localStorage
function ThemeToggle() {
    const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
    
    return (
        <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
            Current: {theme}
        </button>
    );
}

useDebounce — Delay Rapid Changes

function useDebounce<T>(value: T, delay: number): T {
    const [debounced, setDebounced] = useState(value);
    
    useEffect(() => {
        const timer = setTimeout(() => setDebounced(value), delay);
        return () => clearTimeout(timer);
    }, [value, delay]);
    
    return debounced;
}

function SearchPage() {
    const [query, setQuery] = useState("");
    const debouncedQuery = useDebounce(query, 300);
    
    // Only fetches after typing stops for 300ms
    const { data: results } = useFetch(
        debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
    );
    
    return (
        <div>
            <input 
                value={query}
                onChange={e => setQuery(e.target.value)}
                placeholder="Search courses..."
            />
            <ResultsList results={results} />
        </div>
    );
}

useIntersectionObserver — Infinite Scroll / Lazy Loading

function useIntersectionObserver(
    ref: React.RefObject<Element>,
    options?: IntersectionObserverInit
) {
    const [isVisible, setIsVisible] = useState(false);
    
    useEffect(() => {
        if (!ref.current) return;
        
        const observer = new IntersectionObserver(([entry]) => {
            setIsVisible(entry.isIntersecting);
        }, options);
        
        observer.observe(ref.current);
        return () => observer.disconnect();
    }, [ref, options]);
    
    return isVisible;
}

// Lazy-load images when they scroll into view
function LazyImage({ src, alt }: { src: string; alt: string }) {
    const ref = useRef<HTMLDivElement>(null);
    const isVisible = useIntersectionObserver(ref, { rootMargin: "100px" });
    
    return (
        <div ref={ref} className="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
            {isVisible && (
                <img src={src} alt={alt} className="w-full h-full object-cover" />
            )}
        </div>
    );
}

useForm — Form State Management

function useForm<T extends Record<string, string>>(initialValues: T) {
    const [values, setValues] = useState(initialValues);
    const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
    const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
    
    function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) {
        const { name, value } = e.target;
        setValues(prev => ({ ...prev, [name]: value }));
        setErrors(prev => ({ ...prev, [name]: undefined }));
    }
    
    function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
        const { name } = e.target;
        setTouched(prev => ({ ...prev, [name]: true }));
    }
    
    function setError(field: keyof T, message: string) {
        setErrors(prev => ({ ...prev, [field]: message }));
    }
    
    function reset() {
        setValues(initialValues);
        setErrors({});
        setTouched({});
    }
    
    return {
        values,
        errors,
        touched,
        handleChange,
        handleBlur,
        setError,
        reset,
    };
}

// Usage
function LoginForm() {
    const { values, errors, touched, handleChange, handleBlur, setError } = useForm({
        email: "",
        password: "",
    });
    
    async function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        if (!values.email.includes("@")) {
            setError("email", "Valid email required");
            return;
        }
        await login(values.email, values.password);
    }
    
    return (
        <form onSubmit={handleSubmit}>
            <input
                name="email"
                value={values.email}
                onChange={handleChange}
                onBlur={handleBlur}
            />
            {touched.email && errors.email && <p className="error">{errors.email}</p>}
            {/* ... */}
        </form>
    );
}

usePrevious

function usePrevious<T>(value: T): T | undefined {
    const ref = useRef<T>();
    
    useEffect(() => {
        ref.current = value;
    });
    
    return ref.current;
}

function PriceTracker({ price }: { price: number }) {
    const prevPrice = usePrevious(price);
    const direction = prevPrice !== undefined && price > prevPrice ? "up" : "down";
    
    return <span className={direction === "up" ? "text-green-600" : "text-red-600"}>${price}</span>;
}

The Custom Hook Mindset

When you find yourself adding the same useState + useEffect block in a second component, that's the moment to extract a hook. Ask:

  • What's the intent? Name the hook after it: useFetch, useDebounce, useAuth, useCart
  • What state and effects belong together? Keep them in the hook
  • What does the consumer need? Return just that — values, setters, and callbacks

Good custom hooks have a clear responsibility, a descriptive name, and return exactly what's needed — nothing more.

Next lesson: React.memo and avoiding re-renders — understanding React's rendering model and when to optimize.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!