Chapter 2: Custom Hooks

What is a Custom Hook?

A custom hook is a JavaScript function that:

  1. Starts with use (mandatory convention)
  2. Can call other hooks
  3. Encapsulates reusable logic
// Custom hook
function useCounter(initialValue = 0) {
    const [count, setCount] = useState(initialValue);

    const increment = () => setCount(c => c + 1);
    const decrement = () => setCount(c => c - 1);
    const reset = () => setCount(initialValue);

    return { count, increment, decrement, reset };
}

// Usage
function Counter() {
    const { count, increment, decrement, reset } = useCounter(10);

    return (
        <div>
            <p>{count}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
            <button onClick={reset}>Reset</button>
        </div>
    );
}

Why Create Custom Hooks?

  1. Reusability: same logic in multiple components
  2. Separation: isolate logic from display
  3. Tests: easier to test than components
  4. Readability: simpler components

Example: useLocalStorage

Hook that synchronizes state with localStorage:

function useLocalStorage(key, initialValue) {
    // Initialize from localStorage
    const [value, setValue] = useState(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.error(error);
            return initialValue;
        }
    });

    // Update localStorage when value changes
    useEffect(() => {
        try {
            window.localStorage.setItem(key, JSON.stringify(value));
        } catch (error) {
            console.error(error);
        }
    }, [key, value]);

    return [value, setValue];
}

// Usage
function Settings() {
    const [theme, setTheme] = useLocalStorage('theme', 'light');
    const [language, setLanguage] = useLocalStorage('language', 'fr');

    return (
        <div>
            <select value={theme} onChange={e => setTheme(e.target.value)}>
                <option value="light">Light</option>
                <option value="dark">Dark</option>
            </select>
            <select value={language} onChange={e => setLanguage(e.target.value)}>
                <option value="fr">French</option>
                <option value="en">English</option>
            </select>
        </div>
    );
}

Example: useFetch

Hook for API calls:

function useFetch(url) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        let cancelled = false;

        const fetchData = async () => {
            setLoading(true);
            setError(null);

            try {
                const response = await fetch(url);
                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}`);
                }
                const json = await response.json();

                if (!cancelled) {
                    setData(json);
                }
            } catch (err) {
                if (!cancelled) {
                    setError(err.message);
                }
            } finally {
                if (!cancelled) {
                    setLoading(false);
                }
            }
        };

        fetchData();

        return () => {
            cancelled = true;
        };
    }, [url]);

    return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
    const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;

    return <div>{user.name}</div>;
}

Example: useDebounce

Hook to debounce a value (useful for search):

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

    useEffect(() => {
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => {
            clearTimeout(timer);
        };
    }, [value, delay]);

    return debouncedValue;
}

// Usage
function SearchInput({ onSearch }) {
    const [query, setQuery] = useState('');
    const debouncedQuery = useDebounce(query, 300);

    useEffect(() => {
        if (debouncedQuery) {
            onSearch(debouncedQuery);
        }
    }, [debouncedQuery, onSearch]);

    return (
        <input
            value={query}
            onChange={e => setQuery(e.target.value)}
            placeholder="Search..."
        />
    );
}

Example: useToggle

Simple hook for boolean values:

function useToggle(initialValue = false) {
    const [value, setValue] = useState(initialValue);

    const toggle = useCallback(() => {
        setValue(v => !v);
    }, []);

    const setTrue = useCallback(() => setValue(true), []);
    const setFalse = useCallback(() => setValue(false), []);

    return { value, toggle, setTrue, setFalse };
}

// Usage
function Modal() {
    const { value: isOpen, toggle, setFalse: close } = useToggle();

    return (
        <div>
            <button onClick={toggle}>Open/Close</button>
            {isOpen && (
                <div className="modal">
                    <p>Modal content</p>
                    <button onClick={close}>Close</button>
                </div>
            )}
        </div>
    );
}

Example: useForm

Hook for managing forms:

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

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

    const setValue = useCallback((name, value) => {
        setValues(prev => ({ ...prev, [name]: value }));
    }, []);

    const reset = useCallback(() => {
        setValues(initialValues);
        setErrors({});
    }, [initialValues]);

    const validate = useCallback((validationRules) => {
        const newErrors = {};
        for (const [field, rules] of Object.entries(validationRules)) {
            for (const rule of rules) {
                const error = rule(values[field], values);
                if (error) {
                    newErrors[field] = error;
                    break;
                }
            }
        }
        setErrors(newErrors);
        return Object.keys(newErrors).length === 0;
    }, [values]);

    return {
        values,
        errors,
        handleChange,
        setValue,
        reset,
        validate
    };
}

// Usage
function LoginForm({ onSubmit }) {
    const { values, errors, handleChange, validate } = useForm({
        email: '',
        password: ''
    });

    const validationRules = {
        email: [
            v => !v && 'Email required',
            v => !v.includes('@') && 'Invalid email'
        ],
        password: [
            v => !v && 'Password required',
            v => v.length < 6 && 'Minimum 6 characters'
        ]
    };

    const handleSubmit = (e) => {
        e.preventDefault();
        if (validate(validationRules)) {
            onSubmit(values);
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <input
                    name="email"
                    value={values.email}
                    onChange={handleChange}
                    placeholder="Email"
                />
                {errors.email && <span className="error">{errors.email}</span>}
            </div>
            <div>
                <input
                    name="password"
                    type="password"
                    value={values.password}
                    onChange={handleChange}
                    placeholder="Password"
                />
                {errors.password && <span className="error">{errors.password}</span>}
            </div>
            <button type="submit">Login</button>
        </form>
    );
}

Example: useOnClickOutside

Hook to detect clicks outside an element:

function useOnClickOutside(ref, handler) {
    useEffect(() => {
        const listener = (event) => {
            if (!ref.current || ref.current.contains(event.target)) {
                return;
            }
            handler(event);
        };

        document.addEventListener('mousedown', listener);
        document.addEventListener('touchstart', listener);

        return () => {
            document.removeEventListener('mousedown', listener);
            document.removeEventListener('touchstart', listener);
        };
    }, [ref, handler]);
}

// Usage
function Dropdown() {
    const [isOpen, setIsOpen] = useState(false);
    const dropdownRef = useRef(null);

    useOnClickOutside(dropdownRef, () => setIsOpen(false));

    return (
        <div ref={dropdownRef}>
            <button onClick={() => setIsOpen(!isOpen)}>Menu</button>
            {isOpen && (
                <ul className="dropdown-menu">
                    <li>Option 1</li>
                    <li>Option 2</li>
                    <li>Option 3</li>
                </ul>
            )}
        </div>
    );
}

Custom Hooks Rules

  1. Name starts with use: required for React to apply hook rules
  2. Can call other hooks: useState, useEffect, useCallback, other custom hooks
  3. Returns what the component needs: values, functions, objects
  4. Each call is independent: two components using the same hook have separate states

File Organization

src/
  hooks/
    useLocalStorage.js
    useFetch.js
    useDebounce.js
    useToggle.js
    useForm.js
    index.js  // export all hooks
// hooks/index.js
export { useLocalStorage } from './useLocalStorage';
export { useFetch } from './useFetch';
export { useDebounce } from './useDebounce';
export { useToggle } from './useToggle';
export { useForm } from './useForm';

Exercises

Exercise 1: useWindowSize

Create a hook that returns window dimensions and updates on resize.

Solution:

function useWindowSize() {
    const [size, setSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });

    useEffect(() => {
        const handleResize = () => {
            setSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };

        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
    }, []);

    return size;
}

// Usage
function ResponsiveComponent() {
    const { width, height } = useWindowSize();

    return (
        <div>
            Window: {width} x {height}
            {width < 768 && <p>Mobile mode</p>}
        </div>
    );
}

Exercise 2: usePrevious

Create a hook that returns the previous value of a variable.

Solution:

function usePrevious(value) {
    const ref = useRef();

    useEffect(() => {
        ref.current = value;
    }, [value]);

    return ref.current;
}

// Usage
function Counter() {
    const [count, setCount] = useState(0);
    const previousCount = usePrevious(count);

    return (
        <div>
            <p>Current: {count}</p>
            <p>Previous: {previousCount}</p>
            <button onClick={() => setCount(c => c + 1)}>+1</button>
        </div>
    );
}

Key Points to Remember

  1. Custom hook = function starting with use
  2. Encapsulates reusable logic between components
  3. Each call has its own state independent
  4. Can call other hooks (useState, useEffect, etc.)
  5. Simplifies components by extracting logic

<- Previous Chapter | Back to module | Next Chapter: Redux ->