Back to Blog
Mastering Custom React Hooks: Building Reusable Logic with TypeScript

Mastering Custom React Hooks: Building Reusable Logic with TypeScript

Learn to create powerful custom React hooks that encapsulate complex logic, improve code reusability, and provide excellent developer experience with TypeScript integration.

Naim HasanNaim Hasan
09-20-2025
9 min

Custom hooks are one of React's most powerful features, yet many developers underutilize them. They allow you to extract component logic into reusable functions, making your code cleaner, more testable, and easier to maintain. Today, I'll show you how to build custom hooks that solve real-world problems with TypeScript for maximum type safety.

Understanding Custom Hooks

Custom hooks are JavaScript functions that:

  • Start with the prefix "use" (React convention)
  • Can call other hooks (built-in or custom)
  • Encapsulate stateful logic that can be shared between components
  • Return values that components can consume

Think of them as a way to "extract" the logic from your components without changing the hierarchy.

Basic Custom Hook Pattern

Let's start with a simple example - a counter hook:

// hooks/useCounter.ts
import { useState, useCallback } from 'react';

interface UseCounterOptions {
  initialValue?: number;
  min?: number;
  max?: number;
  step?: number;
}

interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  setValue: (value: number) => void;
}

export const useCounter = (options: UseCounterOptions = {}): UseCounterReturn => {
  const {
    initialValue = 0,
    min = -Infinity,
    max = Infinity,
    step = 1
  } = options;

  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(prev => Math.min(max, prev + step));
  }, [max, step]);

  const decrement = useCallback(() => {
    setCount(prev => Math.max(min, prev - step));
  }, [min, step]);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  const setValue = useCallback((value: number) => {
    setCount(Math.max(min, Math.min(max, value)));
  }, [min, max]);

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

Usage in a component:

// components/CounterComponent.tsx
import React from 'react';
import { useCounter } from '../hooks/useCounter';

const CounterComponent: React.FC = () => {
  const { count, increment, decrement, reset } = useCounter({
    initialValue: 0,
    min: 0,
    max: 100,
    step: 5
  });

  return (
    

Counter: {count}

); };

Advanced Custom Hooks

1. API Data Fetching Hook

Let's build a powerful data fetching hook with caching, loading states, and error handling:

// hooks/useFetch.ts
import { useState, useEffect, useCallback, useRef } from 'react';

interface UseFetchOptions {
  initialData?: T;
  enabled?: boolean;
  cacheTime?: number;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

interface UseFetchReturn {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => Promise;
  mutate: (newData: T) => void;
}

// Simple cache implementation
const cache = new Map();

export const useFetch = (
  url: string,
  options: UseFetchOptions = {}
): UseFetchReturn => {
  const {
    initialData = null,
    enabled = true,
    cacheTime = 5 * 60 * 1000, // 5 minutes
    onSuccess,
    onError
  } = options;

  const [data, setData] = useState(initialData);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  const fetchData = useCallback(async () => {
    // Check cache first
    const cached = cache.get(url);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      setData(cached.data);
      onSuccess?.(cached.data);
      return;
    }

    setLoading(true);
    setError(null);

    // Cancel previous request
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();

    try {
      const response = await fetch(url, {
        signal: abortControllerRef.current.signal,
        headers: {
          'Content-Type': 'application/json',
        },
      });

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

      const result: T = await response.json();
      
      // Cache the result
      cache.set(url, { data: result, timestamp: Date.now() });
      
      setData(result);
      onSuccess?.(result);
    } catch (err) {
      if (err instanceof Error && err.name !== 'AbortError') {
        const error = err as Error;
        setError(error);
        onError?.(error);
      }
    } finally {
      setLoading(false);
    }
  }, [url, cacheTime, onSuccess, onError]);

  const mutate = useCallback((newData: T) => {
    setData(newData);
    cache.set(url, { data: newData, timestamp: Date.now() });
  }, [url]);

  useEffect(() => {
    if (enabled) {
      fetchData();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [fetchData, enabled]);

  return {
    data,
    loading,
    error,
    refetch: fetchData,
    mutate
  };
};

2. Local Storage Hook

A hook that syncs state with localStorage and handles serialization:

// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';

type SetValue = (value: T | ((val: T) => T)) => void;

interface UseLocalStorageOptions {
  serializer?: {
    parse: (value: string) => any;
    stringify: (value: any) => string;
  };
}

export const useLocalStorage = (
  key: string,
  initialValue: T,
  options: UseLocalStorageOptions = {}
): [T, SetValue, () => void] => {
  const {
    serializer = {
      parse: JSON.parse,
      stringify: JSON.stringify
    }
  } = options;

  // Get from local storage then parse stored json or return initialValue
  const [storedValue, setStoredValue] = useState(() => {
    try {
      if (typeof window === 'undefined') {
        return initialValue;
      }
      
      const item = window.localStorage.getItem(key);
      return item ? serializer.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // Return a wrapped version of useState's setter function that persists the new value to localStorage
  const setValue: SetValue = useCallback((value) => {
    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 local storage
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, serializer.stringify(valueToStore));
      }
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue, serializer]);

  // Remove from localStorage
  const removeValue = useCallback(() => {
    try {
      setStoredValue(initialValue);
      if (typeof window !== 'undefined') {
        window.localStorage.removeItem(key);
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error);
    }
  }, [key, initialValue]);

  // Listen for changes in other tabs/windows
  useEffect(() => {
    const handleStorageChange = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        try {
          setStoredValue(serializer.parse(e.newValue));
        } catch (error) {
          console.warn(`Error parsing localStorage value for key "${key}":`, error);
        }
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key, serializer]);

  return [storedValue, setValue, removeValue];
};

3. Debounce Hook

Perfect for search inputs and API calls:

// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

interface UseDebounceOptions {
  leading?: boolean;
  trailing?: boolean;
  maxWait?: number;
}

export const useDebounce = (
  value: T,
  delay: number,
  options: UseDebounceOptions = {}
): T => {
  const { leading = false, trailing = true, maxWait } = options;
  
  const [debouncedValue, setDebouncedValue] = useState(
    leading ? value : value
  );
  const [lastCallTime, setLastCallTime] = useState(0);

  useEffect(() => {
    const now = Date.now();
    const timeSinceLastCall = now - lastCallTime;

    // Leading edge
    if (leading && (!lastCallTime || timeSinceLastCall >= delay)) {
      setDebouncedValue(value);
      setLastCallTime(now);
      return;
    }

    // Set up the delayed function call
    const handler = setTimeout(() => {
      if (trailing) {
        setDebouncedValue(value);
      }
      setLastCallTime(Date.now());
    }, delay);

    // Max wait logic
    if (maxWait && timeSinceLastCall >= maxWait) {
      setDebouncedValue(value);
      setLastCallTime(now);
      clearTimeout(handler);
    }

    // Cleanup function to cancel the timeout
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay, leading, trailing, maxWait, lastCallTime]);

  return debouncedValue;
};

// Alternative: Debounce a callback function
export const useDebouncedCallback =  any>(
  callback: T,
  delay: number
): T => {
  const [timeoutId, setTimeoutId] = useState(null);

  const debouncedCallback = useCallback((...args: Parameters) => {
    // Clear existing timeout
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    // Set new timeout
    const newTimeoutId = setTimeout(() => {
      callback(...args);
    }, delay);

    setTimeoutId(newTimeoutId);
  }, [callback, delay, timeoutId]) as T;

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
    };
  }, [timeoutId]);

  return debouncedCallback;
};

Complex State Management Hook

Let's build a shopping cart hook that demonstrates complex state management:

// hooks/useShoppingCart.ts
import { useState, useCallback, useMemo } from 'react';
import { useLocalStorage } from './useLocalStorage';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image?: string;
  variant?: string;
}

interface UseShoppingCartReturn {
  items: CartItem[];
  totalItems: number;
  totalPrice: number;
  addItem: (item: Omit, quantity?: number) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  isInCart: (id: string) => boolean;
  getItem: (id: string) => CartItem | undefined;
}

export const useShoppingCart = (): UseShoppingCartReturn => {
  const [items, setItems] = useLocalStorage('shopping-cart', []);

  // Memoized calculations
  const totalItems = useMemo(() => {
    return items.reduce((sum, item) => sum + item.quantity, 0);
  }, [items]);

  const totalPrice = useMemo(() => {
    return items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  }, [items]);

  // Add item to cart
  const addItem = useCallback((newItem: Omit, quantity = 1) => {
    setItems(currentItems => {
      const existingItemIndex = currentItems.findIndex(item => item.id === newItem.id);
      
      if (existingItemIndex >= 0) {
        // Item exists, update quantity
        const updatedItems = [...currentItems];
        updatedItems[existingItemIndex] = {
          ...updatedItems[existingItemIndex],
          quantity: updatedItems[existingItemIndex].quantity + quantity
        };
        return updatedItems;
      } else {
        // New item, add to cart
        return [...currentItems, { ...newItem, quantity }];
      }
    });
  }, [setItems]);

  // Remove item from cart
  const removeItem = useCallback((id: string) => {
    setItems(currentItems => currentItems.filter(item => item.id !== id));
  }, [setItems]);

  // Update item quantity
  const updateQuantity = useCallback((id: string, quantity: number) => {
    if (quantity <= 0) {
      removeItem(id);
      return;
    }

    setItems(currentItems => 
      currentItems.map(item => 
        item.id === id ? { ...item, quantity } : item
      )
    );
  }, [setItems, removeItem]);

  // Clear entire cart
  const clearCart = useCallback(() => {
    setItems([]);
  }, [setItems]);

  // Check if item is in cart
  const isInCart = useCallback((id: string) => {
    return items.some(item => item.id === id);
  }, [items]);

  // Get specific item
  const getItem = useCallback((id: string) => {
    return items.find(item => item.id === id);
  }, [items]);

  return {
    items,
    totalItems,
    totalPrice,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    isInCart,
    getItem
  };
};

Using the Shopping Cart Hook

// components/ShoppingCart.tsx
import React from 'react';
import { useShoppingCart } from '../hooks/useShoppingCart';

const ShoppingCart: React.FC = () => {
  const {
    items,
    totalItems,
    totalPrice,
    addItem,
    removeItem,
    updateQuantity,
    clearCart
  } = useShoppingCart();

  const sampleProducts = [
    { id: '1', name: 'MacBook Pro', price: 1299, image: '/macbook.jpg' },
    { id: '2', name: 'iPhone 15', price: 899, image: '/iphone.jpg' },
    { id: '3', name: 'AirPods Pro', price: 249, image: '/airpods.jpg' },
  ];

  return (
    
{/* Cart Header */}

Shopping Cart

{totalItems} items

${totalPrice.toFixed(2)}

{/* Product List */}
{sampleProducts.map(product => { const cartItem = items.find(item => item.id === product.id); return (

{product.name}

${product.price}

{cartItem ? (
{cartItem.quantity}
) : ( )}
); })}
{/* Clear Cart */} {items.length > 0 && (
)}
); };

Hook Composition and Best Practices

1. Combining Multiple Hooks

Create powerful compositions by combining simpler hooks:

// hooks/useSearchWithHistory.ts
import { useState, useCallback } from 'react';
import { useDebounce } from './useDebounce';
import { useLocalStorage } from './useLocalStorage';
import { useFetch } from './useFetch';

interface SearchResult {
  id: string;
  title: string;
  description: string;
}

export const useSearchWithHistory = (apiEndpoint: string) => {
  const [query, setQuery] = useState('');
  const [searchHistory, setSearchHistory] = useLocalStorage('search-history', []);
  
  // Debounce the search query
  const debouncedQuery = useDebounce(query, 500);
  
  // Fetch search results
  const { data: results, loading, error } = useFetch(
    debouncedQuery ? `${apiEndpoint}?q=${encodeURIComponent(debouncedQuery)}` : '',
    { enabled: !!debouncedQuery }
  );

  // Add to search history
  const addToHistory = useCallback((searchTerm: string) => {
    if (searchTerm.trim() && !searchHistory.includes(searchTerm)) {
      setSearchHistory(prev => [searchTerm, ...prev].slice(0, 10)); // Keep last 10 searches
    }
  }, [searchHistory, setSearchHistory]);

  // Search function
  const search = useCallback((searchTerm: string) => {
    setQuery(searchTerm);
    if (searchTerm.trim()) {
      addToHistory(searchTerm);
    }
  }, [addToHistory]);

  // Clear history
  const clearHistory = useCallback(() => {
    setSearchHistory([]);
  }, [setSearchHistory]);

  return {
    query,
    setQuery,
    search,
    results: results || [],
    loading,
    error,
    searchHistory,
    clearHistory
  };
};

2. Testing Custom Hooks

Custom hooks are easy to test with React Testing Library:

// __tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../hooks/useCounter';

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('should increment count', () => {
    const { result } = renderHook(() => useCounter({ step: 5 }));
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(5);
  });

  it('should respect max value', () => {
    const { result } = renderHook(() => useCounter({ max: 10 }));
    
    act(() => {
      result.current.setValue(15);
    });
    
    expect(result.current.count).toBe(10);
  });
});

Performance Considerations

1. Memoization

Use useMemo and useCallback to prevent unnecessary recalculations and re-renders:

// ❌ Bad: Function recreated on every render
const addItem = (item) => {
  setItems(prev => [...prev, item]);
};

// ✅ Good: Function memoized
const addItem = useCallback((item) => {
  setItems(prev => [...prev, item]);
}, []);

2. Cleanup

Always clean up side effects to prevent memory leaks:

useEffect(() => {
  const controller = new AbortController();
  
  fetchData(controller.signal);
  
  return () => {
    controller.abort(); // Cleanup
  };
}, []);

Common Patterns and Tips

1. Return Objects vs Arrays

  • Return arrays when the order matters and you want array destructuring: const [value, setValue] = useState()
  • Return objects when you have multiple named values: const { data, loading, error } = useFetch()

2. TypeScript Best Practices

  • Always define proper interfaces for your hook parameters and return values
  • Use generic types when your hook works with different data types
  • Provide default values for optional parameters

3. Naming Conventions

  • Always prefix custom hooks with "use"
  • Use descriptive names that explain what the hook does
  • Follow established patterns: useXxx, useXxxState, useXxxEffect

Wrapping Up

Custom hooks are incredibly powerful tools that can transform your React development experience. They provide:

  • Code Reusability: Share logic across multiple components
  • Separation of Concerns: Keep components focused on rendering
  • Easier Testing: Test logic in isolation from components
  • Better Organization: Group related stateful logic together
  • Type Safety: Full TypeScript support with proper interfaces
  • Performance: Optimize with memoization and proper cleanup

Start by identifying repeated patterns in your components, then extract them into custom hooks. Begin with simple hooks like useToggle or useLocalStorage, then gradually build more complex ones as you gain confidence.

The hooks we've built today - from the simple counter to the complex shopping cart - demonstrate how custom hooks can encapsulate everything from basic state management to complex business logic. They make your code more maintainable, testable, and reusable across your entire application.

Remember: the best custom hook is one that solves a real problem in your codebase and makes your components cleaner and more focused on their primary responsibility - rendering UI.

#React#Custom Hooks#TypeScript#State Management#Reusability#Best Practices