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

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

SHEMANTI PAL
SHEMANTI PAL
Jun 9, 2025
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.

typescript
1import { useState } from 'react'; 2 3// Define the return type of our hook 4type UseToggleReturn = [boolean, () => void]; 5 6function useToggle(initialState: boolean = false): UseToggleReturn { 7 const [state, setState] = useState<boolean>(initialState); 8 9 // Create a function that toggles the state 10 const toggle = () => { 11 setState(prevState => !prevState); 12 }; 13 14 // Return both the state and the toggle function 15 return [state, toggle]; 16} 17 18export default useToggle;

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

typescript
1import React from 'react'; 2import useToggle from './hooks/useToggle'; 3 4const ToggleExample: React.FC = () => { 5 const [isOpen, toggle] = useToggle(false); 6 7 return ( 8 <div> 9 <button onClick={toggle}> 10 {isOpen ? 'Close' : 'Open'} 11 </button> 12 13 {isOpen && ( 14 <div className="modal"> 15 This content is now visible! 16 </div> 17 )} 18 </div> 19 ); 20};

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.

typescript
1import { useState, useEffect } from 'react'; 2 3function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] { 4 // Get stored value from localStorage or use initialValue 5 const getStoredValue = (): T => { 6 try { 7 const item = window.localStorage.getItem(key); 8 return item ? JSON.parse(item) : initialValue; 9 } catch (error) { 10 console.error(`Error reading localStorage key "${key}":`, error); 11 return initialValue; 12 } 13 }; 14 15 // State to store our value 16 const [storedValue, setStoredValue] = useState<T>(getStoredValue); 17 18 // Return a wrapped version of useState's setter function that persists the new value to localStorage 19 const setValue = (value: T | ((val: T) => T)) => { 20 try { 21 // Allow value to be a function so we have the same API as useState 22 const valueToStore = value instanceof Function ? value(storedValue) : value; 23 24 // Save state 25 setStoredValue(valueToStore); 26 27 // Save to localStorage 28 window.localStorage.setItem(key, JSON.stringify(valueToStore)); 29 } catch (error) { 30 console.error(`Error setting localStorage key "${key}":`, error); 31 } 32 }; 33 34 // Update localStorage if the key changes 35 useEffect(() => { 36 const savedValue = getStoredValue(); 37 setStoredValue(savedValue); 38 }, [key]); 39 40 return [storedValue, setValue]; 41} 42 43export 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:

typescript
1import React from 'react'; 2import useLocalStorage from './hooks/useLocalStorage'; 3 4const UserPreferences: React.FC = () => { 5 const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light'); 6 7 return ( 8 <div className={`app ${theme}`}> 9 <h1>User Preferences</h1> 10 <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> 11 Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode 12 </button> 13 </div> 14 ); 15};

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:

typescript
1import { useState, useEffect, useRef } from 'react'; 2 3interface UseFetchOptions { 4 headers?: HeadersInit; 5 cache?: RequestCache; 6 // Add more options as needed 7} 8 9interface UseFetchState<T> { 10 data: T | null; 11 isLoading: boolean; 12 error: Error | null; 13} 14 15function useFetch<T>(url: string, options?: UseFetchOptions): UseFetchState<T> { 16 // State for our data, loading status, and errors 17 const [state, setState] = useState<UseFetchState<T>>({ 18 data: null, 19 isLoading: true, 20 error: null, 21 }); 22 23 // Keep track of if the component is still mounted 24 const isMounted = useRef<boolean>(true); 25 26 useEffect(() => { 27 // Set isMounted to false when we unmount 28 return () => { 29 isMounted.current = false; 30 }; 31 }, []); 32 33 useEffect(() => { 34 // Reset state when url changes 35 if (isMounted.current) { 36 setState({ data: null, isLoading: true, error: null }); 37 } 38 39 // Create an AbortController for the fetch request 40 const controller = new AbortController(); 41 const signal = controller.signal; 42 43 const fetchData = async () => { 44 try { 45 const response = await fetch(url, { 46 ...options, 47 signal, 48 }); 49 50 if (!response.ok) { 51 throw new Error(`HTTP error! Status: ${response.status}`); 52 } 53 54 const result = await response.json(); 55 56 if (isMounted.current) { 57 setState({ 58 data: result, 59 isLoading: false, 60 error: null, 61 }); 62 } 63 } catch (error) { 64 if (error.name === 'AbortError') { 65 // Fetch was aborted, do nothing 66 return; 67 } 68 69 if (isMounted.current) { 70 setState({ 71 data: null, 72 isLoading: false, 73 error: error instanceof Error ? error : new Error('Unknown error occurred'), 74 }); 75 } 76 } 77 }; 78 79 fetchData(); 80 81 // Cleanup function to abort fetch if component unmounts or url changes 82 return () => { 83 controller.abort(); 84 }; 85 }, [url, JSON.stringify(options)]); 86 87 return state; 88} 89 90export 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:

typescript
1import { useFetch } from './hooks/useFetch'; 2 3interface User { 4 id: number; 5 name: string; 6 email: string; 7} 8 9const UserProfile: React.FC<{ userId: number }> = ({ userId }) => { 10 const { data, isLoading, error } = useFetch<User>( 11 `https://api.example.com/users/${userId}` 12 ); 13 14 if (isLoading) return <div>Loading...</div>; 15 if (error) return <div>Error: {error.message}</div>; 16 if (!data) return <div>No user found</div>; 17 18 return ( 19 <div> 20 <h1>{data.name}</h1> 21 <p>Email: {data.email}</p> 22 </div> 23 ); 24};

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:

typescript
1import { useState, ChangeEvent, FormEvent } from 'react'; 2 3// Define the types for our hook 4type FormErrors<T> = Partial<Record<keyof T, string>>; 5type Validator<T> = (values: T) => FormErrors<T>; 6 7interface UseFormOptions<T> { 8 initialValues: T; 9 validate?: Validator<T>; 10 onSubmit: (values: T) => void | Promise<void>; 11} 12 13interface UseFormReturn<T> { 14 values: T; 15 errors: FormErrors<T>; 16 touched: Partial<Record<keyof T, boolean>>; 17 isSubmitting: boolean; 18 handleChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void; 19 handleBlur: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void; 20 handleSubmit: (e: FormEvent<HTMLFormElement>) => void; 21 reset: () => void; 22} 23 24function useForm<T extends Record<string, any>>({ 25 initialValues, 26 validate, 27 onSubmit, 28}: UseFormOptions<T>): UseFormReturn<T> { 29 const [values, setValues] = useState<T>(initialValues); 30 const [errors, setErrors] = useState<FormErrors<T>>({}); 31 const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({}); 32 const [isSubmitting, setIsSubmitting] = useState<boolean>(false); 33 34 // Validate form values 35 const validateForm = (): FormErrors<T> => { 36 if (!validate) return {}; 37 return validate(values); 38 }; 39 40 // Handle input changes 41 const handleChange = ( 42 e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> 43 ): void => { 44 const { name, value, type } = e.target; 45 46 // Special handling for checkboxes 47 const val = type === 'checkbox' 48 ? (e.target as HTMLInputElement).checked 49 : value; 50 51 setValues((prev) => ({ 52 ...prev, 53 [name]: val, 54 })); 55 }; 56 57 // Handle input blur events (to track which fields were touched) 58 const handleBlur = ( 59 e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> 60 ): void => { 61 const { name } = e.target; 62 63 setTouched((prev) => ({ 64 ...prev, 65 [name]: true, 66 })); 67 68 // Validate on blur 69 const validationErrors = validateForm(); 70 setErrors(validationErrors); 71 }; 72 73 // Handle form submission 74 const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => { 75 e.preventDefault(); 76 77 // Mark all fields as touched 78 const touchedFields = Object.keys(values).reduce((acc, key) => { 79 acc[key as keyof T] = true; 80 return acc; 81 }, {} as Partial<Record<keyof T, boolean>>); 82 83 setTouched(touchedFields); 84 85 // Validate all fields 86 const validationErrors = validateForm(); 87 setErrors(validationErrors); 88 89 // If no errors, submit the form 90 if (Object.keys(validationErrors).length === 0) { 91 setIsSubmitting(true); 92 93 try { 94 await onSubmit(values); 95 } catch (error) { 96 console.error('Form submission error:', error); 97 } finally { 98 setIsSubmitting(false); 99 } 100 } 101 }; 102 103 // Reset the form to its initial state 104 const reset = (): void => { 105 setValues(initialValues); 106 setErrors({}); 107 setTouched({}); 108 setIsSubmitting(false); 109 }; 110 111 return { 112 values, 113 errors, 114 touched, 115 isSubmitting, 116 handleChange, 117 handleBlur, 118 handleSubmit, 119 reset 120 }; 121} 122 123export 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:

typescript
1import React from 'react'; 2import useForm from './hooks/useForm'; 3 4interface LoginFormValues { 5 email: string; 6 password: string; 7 rememberMe: boolean; 8} 9 10const LoginForm: React.FC = () => { 11 const initialValues: LoginFormValues = { 12 email: '', 13 password: '', 14 rememberMe: false, 15 }; 16 17 // Validation function 18 const validate = (values: LoginFormValues) => { 19 const errors: Partial<Record<keyof LoginFormValues, string>> = {}; 20 21 if (!values.email) { 22 errors.email = 'Email is required'; 23 } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email)) { 24 errors.email = 'Invalid email address'; 25 } 26 27 if (!values.password) { 28 errors.password = 'Password is required'; 29 } else if (values.password.length < 6) { 30 errors.password = 'Password must be at least 6 characters'; 31 } 32 33 return errors; 34 }; 35 36 // Form submission handler 37 const handleLoginSubmit = async (values: LoginFormValues) => { 38 console.log('Form submitted with:', values); 39 // Simulate API call 40 await new Promise(resolve => setTimeout(resolve, 1000)); 41 alert('Login successful!'); 42 }; 43 44 // Use our custom form hook 45 const { 46 values, 47 errors, 48 touched, 49 isSubmitting, 50 handleChange, 51 handleBlur, 52 handleSubmit, 53 reset 54 } = useForm<LoginFormValues>({ 55 initialValues, 56 validate, 57 onSubmit: handleLoginSubmit, 58 }); 59 60 return ( 61 <form onSubmit={handleSubmit}> 62 <div> 63 <label htmlFor="email">Email</label> 64 <input 65 type="email" 66 id="email" 67 name="email" 68 value={values.email} 69 onChange={handleChange} 70 onBlur={handleBlur} 71 /> 72 {touched.email && errors.email && ( 73 <div className="error">{errors.email}</div> 74 )} 75 </div> 76 77 <div> 78 <label htmlFor="password">Password</label> 79 <input 80 type="password" 81 id="password" 82 name="password" 83 value={values.password} 84 onChange={handleChange} 85 onBlur={handleBlur} 86 /> 87 {touched.password && errors.password && ( 88 <div className="error">{errors.password}</div> 89 )} 90 </div> 91 92 <div> 93 <label> 94 <input 95 type="checkbox" 96 name="rememberMe" 97 checked={values.rememberMe} 98 onChange={handleChange} 99 /> 100 Remember me 101 </label> 102 </div> 103 104 <button type="submit" disabled={isSubmitting}> 105 {isSubmitting ? 'Logging in...' : 'Log In'} 106 </button> 107 <button type="button" onClick={reset}> 108 Reset 109 </button> 110 </form> 111 ); 112};

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.

typescript
1/** 2 * Hook for managing form state with validation and submission 3 * 4 * @param options.initialValues - Initial form values 5 * @param options.validate - Optional validation function 6 * @param options.onSubmit - Form submission handler 7 * 8 * @returns Object containing form state and handlers 9 */ 10function useForm<T extends Record<string, any>>({ 11 initialValues, 12 validate, 13 onSubmit, 14}: UseFormOptions<T>): UseFormReturn<T> { 15 // Implementation... 16}

5. Test Your Hooks

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

typescript
1import { renderHook, act } from '@testing-library/react-hooks'; 2import useToggle from './useToggle'; 3 4test('should toggle value', () => { 5 const { result } = renderHook(() => useToggle(false)); 6 7 // Initial value should be false 8 expect(result.current[0]).toBe(false); 9 10 // Toggle the value 11 act(() => { 12 result.current[1](); 13 }); 14 15 // Value should now be true 16 expect(result.current[0]).toBe(true); 17});

Common Custom Hook Patterns

Here are some other common custom hook patterns worth exploring:

1. useDebounce

Debounces a value, perfect for search inputs:

typescript
1function useDebounce<T>(value: T, delay: number): T { 2 const [debouncedValue, setDebouncedValue] = useState<T>(value); 3 4 useEffect(() => { 5 const timer = setTimeout(() => { 6 setDebouncedValue(value); 7 }, delay); 8 9 return () => { 10 clearTimeout(timer); 11 }; 12 }, [value, delay]); 13 14 return debouncedValue; 15}

2. useMediaQuery

Helps with responsive designs:

typescript
1function useMediaQuery(query: string): boolean { 2 const [matches, setMatches] = useState<boolean>(false); 3 4 useEffect(() => { 5 const mediaQuery = window.matchMedia(query); 6 setMatches(mediaQuery.matches); 7 8 const handler = (event: MediaQueryListEvent) => { 9 setMatches(event.matches); 10 }; 11 12 mediaQuery.addEventListener('change', handler); 13 14 return () => { 15 mediaQuery.removeEventListener('change', handler); 16 }; 17 }, [query]); 18 19 return matches; 20}

3. useAsync

For handling any async operation:

typescript
1function useAsync<T, E = string>( 2 asyncFunction: () => Promise<T>, 3 immediate = true 4) { 5 const [status, setStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle'); 6 const [value, setValue] = useState<T | null>(null); 7 const [error, setError] = useState<E | null>(null); 8 9 // Execute the async function 10 const execute = useCallback(() => { 11 setStatus('pending'); 12 setValue(null); 13 setError(null); 14 15 return asyncFunction() 16 .then((response) => { 17 setValue(response); 18 setStatus('success'); 19 return response; 20 }) 21 .catch((error) => { 22 setError(error); 23 setStatus('error'); 24 throw error; 25 }); 26 }, [asyncFunction]); 27 28 // Call execute if immediate is true 29 useEffect(() => { 30 if (immediate) { 31 execute(); 32 } 33 }, [execute, immediate]); 34 35 return { execute, status, value, error }; 36}

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!

Related Articles

Categories

Docker
containerization
container orchestration
TypeScript
React
LinkedIn
jobs
Scraping
hooks
Docker optimization
How to optimize Docker images for Next.js applications
Best practices for Docker image optimization in Next.js
Improving Next.js performance with Docker Reducing Docker image size for Next.js apps
Multi-stage builds for Next.js Docker images
Next.js performance
docker images
Web Development
GitHub
Git
merge
git rebase
git merge --squash
prepverse
Data Science
dataanalytics
data analysis
ReduxVsZustand
zustand
Zustand tutorial
State Management
Redux
redux-toolkit
technology
version control
github-actions
Zustand store
repository
2025 technology trends
opensource
Developer
portfolio
preparation
interview
engineering
Interview tips
#ai-tools
Technical Skills
remote jobs
Technical interview
JavaScript
Open Source
software development