How to Build a Custom Hook in React with TypeScript (Step-by-Step Guide)

SHEMANTI PALSHEMANTI PAL
11 min read

When I first started working with React hooks, they seemed like magic. But after building dozens of custom hooks for various projects, I've come to see them as an essential tool in my React toolkit. Today, I want to share what I've learned about creating custom hooks with TypeScript, walking through the process step by step with practical examples.

What Are Custom Hooks (and Why Should You Care)?

Custom hooks are JavaScript functions that use React's built-in hooks to create reusable logic. That's it - no magic, just functions. But they're incredibly powerful because they let you extract complex logic from components, making your code cleaner and more reusable.

Before hooks, we had to use class components, higher-order components, or render props to reuse stateful logic. Now, we can just create a function that uses hooks internally.

When Should You Create a Custom Hook?

You should consider creating a custom hook when:

  1. You find yourself copying and pasting the same stateful logic across components

  2. A component is getting too complex with too many concerns

  3. You want to reuse logic that interacts with React's lifecycle

Let's start simple and work our way up to more complex examples.

Building Your First Custom Hook: useToggle

Let's start with a simple but incredibly useful custom hook: useToggle. This hook manages a boolean state that can be toggled on and off - perfect for modals, dropdowns, and other UI elements.

import { useState } from 'react';

// Define the return type of our hook
type UseToggleReturn = [boolean, () => void];

function useToggle(initialState: boolean = false): UseToggleReturn {
  const [state, setState] = useState<boolean>(initialState);

  // Create a function that toggles the state
  const toggle = () => {
    setState(prevState => !prevState);
  };

  // Return both the state and the toggle function
  return [state, toggle];
}

export default useToggle;

Here's how you would use it in a component:

import React from 'react';
import useToggle from './hooks/useToggle';

const ToggleExample: React.FC = () => {
  const [isOpen, toggle] = useToggle(false);

  return (
    <div>
      <button onClick={toggle}>
        {isOpen ? 'Close' : 'Open'}
      </button>

      {isOpen && (
        <div className="modal">
          This content is now visible!
        </div>
      )}
    </div>
  );
};

Pretty simple, right? But even this tiny hook removes the need to write the same useState + toggle function logic over and over.

A More Useful Custom Hook: useLocalStorage

Now let's create something more useful: a hook that syncs state with local storage. This is perfect for persisting user preferences or form data across page reloads.

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
  // Get stored value from localStorage or use initialValue
  const getStoredValue = (): T => {
    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;
    }
  };

  // State to store our value
  const [storedValue, setStoredValue] = useState<T>(getStoredValue);

  // Return a wrapped version of useState's setter function that persists the new value to localStorage
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      // Allow value to be a function so we have the same API as useState
      const valueToStore = value instanceof Function ? value(storedValue) : value;

      // Save state
      setStoredValue(valueToStore);

      // Save to localStorage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };

  // Update localStorage if the key changes
  useEffect(() => {
    const savedValue = getStoredValue();
    setStoredValue(savedValue);
  }, [key]);

  return [storedValue, setValue];
}

export default useLocalStorage;

Let's break down what's happening:

  1. We're using generics (<T>) to make our hook work with any data type

  2. We check localStorage for an existing value with our key, falling back to the initial value

  3. We wrap the setter function to sync changes to localStorage

  4. We use useEffect to update our state if the key changes

Here's how you would use it:

import React from 'react';
import useLocalStorage from './hooks/useLocalStorage';

const UserPreferences: React.FC = () => {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

  return (
    <div className={`app ${theme}`}>
      <h1>User Preferences</h1>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
      </button>
    </div>
  );
};

Now the user's theme preference will persist across page reloads!

Building a Data Fetching Hook: useFetch

Data fetching is one of the most common use cases for custom hooks. Let's build a useFetch hook that handles loading states, errors, and caching:

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

interface UseFetchOptions {
  headers?: HeadersInit;
  cache?: RequestCache;
  // Add more options as needed
}

interface UseFetchState<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
}

function useFetch<T>(url: string, options?: UseFetchOptions): UseFetchState<T> {
  // State for our data, loading status, and errors
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    isLoading: true,
    error: null,
  });

  // Keep track of if the component is still mounted
  const isMounted = useRef<boolean>(true);

  useEffect(() => {
    // Set isMounted to false when we unmount
    return () => {
      isMounted.current = false;
    };
  }, []);

  useEffect(() => {
    // Reset state when url changes
    if (isMounted.current) {
      setState({ data: null, isLoading: true, error: null });
    }

    // Create an AbortController for the fetch request
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchData = async () => {
      try {
        const response = await fetch(url, {
          ...options,
          signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }

        const result = await response.json();

        if (isMounted.current) {
          setState({
            data: result,
            isLoading: false,
            error: null,
          });
        }
      } catch (error) {
        if (error.name === 'AbortError') {
          // Fetch was aborted, do nothing
          return;
        }

        if (isMounted.current) {
          setState({
            data: null,
            isLoading: false,
            error: error instanceof Error ? error : new Error('Unknown error occurred'),
          });
        }
      }
    };

    fetchData();

    // Cleanup function to abort fetch if component unmounts or url changes
    return () => {
      controller.abort();
    };
  }, [url, JSON.stringify(options)]);

  return state;
}

export default useFetch;

This hook:

  1. Tracks loading state, data, and errors

  2. Handles cleanup when components unmount

  3. Aborts ongoing requests when the URL changes

  4. Prevents state updates after unmounting (a common cause of memory leaks)

Here's how you would use it:

import { useFetch } from './hooks/useFetch';

interface User {
  id: number;
  name: string;
  email: string;
}

const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
  const { data, isLoading, error } = useFetch<User>(
    `https://api.example.com/users/${userId}`
  );

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return <div>No user found</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>Email: {data.email}</p>
    </div>
  );
};

Advanced Example: useForm Hook

Form handling is another perfect use case for custom hooks. Let's build a useForm hook that manages form state, validation, and submission:

import { useState, ChangeEvent, FormEvent } from 'react';

// Define the types for our hook
type FormErrors<T> = Partial<Record<keyof T, string>>;
type Validator<T> = (values: T) => FormErrors<T>;

interface UseFormOptions<T> {
  initialValues: T;
  validate?: Validator<T>;
  onSubmit: (values: T) => void | Promise<void>;
}

interface UseFormReturn<T> {
  values: T;
  errors: FormErrors<T>;
  touched: Partial<Record<keyof T, boolean>>;
  isSubmitting: boolean;
  handleChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
  handleBlur: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
  handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
  reset: () => void;
}

function useForm<T extends Record<string, any>>({
  initialValues,
  validate,
  onSubmit,
}: UseFormOptions<T>): UseFormReturn<T> {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<FormErrors<T>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);

  // Validate form values
  const validateForm = (): FormErrors<T> => {
    if (!validate) return {};
    return validate(values);
  };

  // Handle input changes
  const handleChange = (
    e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
  ): void => {
    const { name, value, type } = e.target;

    // Special handling for checkboxes
    const val = type === 'checkbox' 
      ? (e.target as HTMLInputElement).checked 
      : value;

    setValues((prev) => ({
      ...prev,
      [name]: val,
    }));
  };

  // Handle input blur events (to track which fields were touched)
  const handleBlur = (
    e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
  ): void => {
    const { name } = e.target;

    setTouched((prev) => ({
      ...prev,
      [name]: true,
    }));

    // Validate on blur
    const validationErrors = validateForm();
    setErrors(validationErrors);
  };

  // Handle form submission
  const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
    e.preventDefault();

    // Mark all fields as touched
    const touchedFields = Object.keys(values).reduce((acc, key) => {
      acc[key as keyof T] = true;
      return acc;
    }, {} as Partial<Record<keyof T, boolean>>);

    setTouched(touchedFields);

    // Validate all fields
    const validationErrors = validateForm();
    setErrors(validationErrors);

    // If no errors, submit the form
    if (Object.keys(validationErrors).length === 0) {
      setIsSubmitting(true);

      try {
        await onSubmit(values);
      } catch (error) {
        console.error('Form submission error:', error);
      } finally {
        setIsSubmitting(false);
      }
    }
  };

  // Reset the form to its initial state
  const reset = (): void => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  };

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  };
}

export default useForm;

This hook provides a comprehensive solution for form handling:

  1. It tracks form values, errors, and which fields have been touched

  2. It handles validation on blur and submission

  3. It supports asynchronous submission

  4. It provides a reset function to clear the form

Here's how you would use it:

import React from 'react';
import useForm from './hooks/useForm';

interface LoginFormValues {
  email: string;
  password: string;
  rememberMe: boolean;
}

const LoginForm: React.FC = () => {
  const initialValues: LoginFormValues = {
    email: '',
    password: '',
    rememberMe: false,
  };

  // Validation function
  const validate = (values: LoginFormValues) => {
    const errors: Partial<Record<keyof LoginFormValues, string>> = {};

    if (!values.email) {
      errors.email = 'Email is required';
    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) {
      errors.email = 'Invalid email address';
    }

    if (!values.password) {
      errors.password = 'Password is required';
    } else if (values.password.length < 6) {
      errors.password = 'Password must be at least 6 characters';
    }

    return errors;
  };

  // Form submission handler
  const handleLoginSubmit = async (values: LoginFormValues) => {
    console.log('Form submitted with:', values);
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    alert('Login successful!');
  };

  // Use our custom form hook
  const { 
    values, 
    errors, 
    touched, 
    isSubmitting, 
    handleChange, 
    handleBlur, 
    handleSubmit, 
    reset 
  } = useForm<LoginFormValues>({
    initialValues,
    validate,
    onSubmit: handleLoginSubmit,
  });

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.email && errors.email && (
          <div className="error">{errors.email}</div>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.password && errors.password && (
          <div className="error">{errors.password}</div>
        )}
      </div>

      <div>
        <label>
          <input
            type="checkbox"
            name="rememberMe"
            checked={values.rememberMe}
            onChange={handleChange}
          />
          Remember me
        </label>
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Log In'}
      </button>
      <button type="button" onClick={reset}>
        Reset
      </button>
    </form>
  );
};

Tips for Building Great Custom Hooks

After building dozens of custom hooks, here are some best practices I've learned:

1. Keep It Focused

A good hook should do one thing well. If your hook is handling too many concerns, split it into multiple hooks.

2. Write Clear Return Types

TypeScript really shines with hooks because it helps document what your hook returns. Be explicit about return types, especially if your hook returns multiple values.

3. Consider Edge Cases

Think about potential edge cases:

  • What happens if the component unmounts during an async operation?

  • How should errors be handled?

  • What initial values make sense?

4. Document Your Hooks

Even with TypeScript, it's helpful to add comments explaining what your hook does, especially for complex hooks that others might use.

/**
 * Hook for managing form state with validation and submission
 * 
 * @param options.initialValues - Initial form values
 * @param options.validate - Optional validation function
 * @param options.onSubmit - Form submission handler
 * 
 * @returns Object containing form state and handlers
 */
function useForm<T extends Record<string, any>>({
  initialValues,
  validate,
  onSubmit,
}: UseFormOptions<T>): UseFormReturn<T> {
  // Implementation...
}

5. Test Your Hooks

Custom hooks can and should be tested! Use React Testing Library's renderHook function to test your hooks in isolation:

import { renderHook, act } from '@testing-library/react-hooks';
import useToggle from './useToggle';

test('should toggle value', () => {
  const { result } = renderHook(() => useToggle(false));

  // Initial value should be false
  expect(result.current[0]).toBe(false);

  // Toggle the value
  act(() => {
    result.current[1]();
  });

  // Value should now be true
  expect(result.current[0]).toBe(true);
});

Common Custom Hook Patterns

Here are some other common custom hook patterns worth exploring:

1. useDebounce

Debounces a value, perfect for search inputs:

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

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

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

  return debouncedValue;
}

2. useMediaQuery

Helps with responsive designs:

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState<boolean>(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    setMatches(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setMatches(event.matches);
    };

    mediaQuery.addEventListener('change', handler);

    return () => {
      mediaQuery.removeEventListener('change', handler);
    };
  }, [query]);

  return matches;
}

3. useAsync

For handling any async operation:

function useAsync<T, E = string>(
  asyncFunction: () => Promise<T>,
  immediate = true
) {
  const [status, setStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
  const [value, setValue] = useState<T | null>(null);
  const [error, setError] = useState<E | null>(null);

  // Execute the async function
  const execute = useCallback(() => {
    setStatus('pending');
    setValue(null);
    setError(null);

    return asyncFunction()
      .then((response) => {
        setValue(response);
        setStatus('success');
        return response;
      })
      .catch((error) => {
        setError(error);
        setStatus('error');
        throw error;
      });
  }, [asyncFunction]);

  // Call execute if immediate is true
  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

  return { execute, status, value, error };
}

Conclusion: Custom Hooks Make Better React Code

They let you:

  1. Reuse Logic: Share stateful logic between components without complex patterns

  2. Simplify Components: Extract complex logic from your components

  3. Improve Testing: Test logic separately from your components

  4. Create Better Abstractions: Hide implementation details behind clean APIs

And when combined with TypeScript, hooks become even more powerful through better autocompletion, type checking, and self-documentation.

Start with simple hooks and gradually build more complex ones as you become comfortable with the pattern. Soon, you'll find yourself reaching for custom hooks whenever you need to share logic between components.

What's your favorite custom hook? Have you created any unique hooks for specific problems? Share your experiences in the comments!

0
Subscribe to my newsletter

Read articles from SHEMANTI PAL directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

SHEMANTI PAL
SHEMANTI PAL