DEV Community

Cover image for 6 Advanced Techniques for Building Powerful Custom React Hooks
Aarav Joshi
Aarav Joshi

Posted on

6 Advanced Techniques for Building Powerful Custom React Hooks

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

JavaScript has revolutionized modern web development, and React's introduction of hooks transformed how we build components. In my years of React development, I've found that custom hooks offer exceptional power for code organization and reuse. Here are six techniques I use to build efficient custom React hooks that boost performance and maintainability.

When I create custom React hooks, I focus on making them lean, purposeful, and optimized. These tools have become essential in my development toolkit, helping me solve complex problems with elegant solutions.

Clean Dependency Management

Managing dependencies correctly is critical for optimal hook performance. I've learned that imprecise dependency arrays lead to unnecessary re-renders and calculations.

// Problematic approach
function useUserData(userId) {
  const [userData, setUserData] = useState(null);

  // This object creates a new reference every render
  const options = { credentials: 'include' };

  useEffect(() => {
    fetch(`/api/users/${userId}`, options)
      .then(res => res.json())
      .then(data => setUserData(data));
  }, [userId, options]); // options changes every render!

  return userData;
}

// Improved approach
function useUserData(userId) {
  const [userData, setUserData] = useState(null);

  // Memoize the options object
  const options = useMemo(() => ({ 
    credentials: 'include' 
  }), []); // Empty dependency array - never changes

  useEffect(() => {
    fetch(`/api/users/${userId}`, options)
      .then(res => res.json())
      .then(data => setUserData(data));
  }, [userId, options]); // options is stable now

  return userData;
}
Enter fullscreen mode Exit fullscreen mode

I always extract static values outside the hook body and use useMemo() for complex objects. For functions, I prefer useCallback() to maintain referential stability. This attention to dependency management has eliminated many performance issues in my applications.

State Initialization Patterns

Proper state initialization can significantly impact hook performance. I've adopted lazy initialization to avoid expensive calculations during renders.

// Without lazy initialization
function useLocalStorageState(key, defaultValue) {
  // This runs on EVERY render
  const storedValue = JSON.parse(localStorage.getItem(key)) || defaultValue;
  const [state, setState] = useState(storedValue);

  // Rest of hook logic...
  return [state, setState];
}

// With lazy initialization
function useLocalStorageState(key, defaultValue) {
  // This runs only on initial render
  const [state, setState] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : defaultValue;
    } catch (error) {
      console.error("Error reading from localStorage:", error);
      return defaultValue;
    }
  });

  // Rest of hook logic...
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

When I need to compute initial state from props, I always use the function form of useState(). This ensures the calculation happens only once during mounting, rather than on every render cycle. This pattern has been particularly valuable in data-intensive applications I've built.

Effect Cleanup Optimization

Proper cleanup is essential for hooks that create subscriptions, intervals, or listeners. I've seen many memory leaks from improper cleanup, which I now meticulously avoid.

// Basic interval hook with proper cleanup
function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  // Keep the callback reference fresh
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;

    const tick = () => savedCallback.current();
    const id = setInterval(tick, delay);

    // Clean up on unmount or delay change
    return () => clearInterval(id);
  }, [delay]);
}

// More complex example with event management
function useWindowResize(handler) {
  const handlerRef = useRef(handler);

  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  useEffect(() => {
    const handleResize = (event) => {
      handlerRef.current(event);
    };

    window.addEventListener('resize', handleResize);

    // Return cleanup function
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty dependency array means setup and cleanup run once
}
Enter fullscreen mode Exit fullscreen mode

I ensure cleanup functions handle all resources, particularly for asynchronous operations. Using AbortController for fetch requests has become standard practice in my hooks, preventing race conditions and memory leaks.

Composition Strategy

Breaking complex hooks into smaller ones has transformed my code organization. I apply the single responsibility principle rigorously when designing custom hooks.

// Instead of one massive hook that does everything...
function useProductPage(productId) {
  // Decompose into smaller, focused hooks
  const product = useProduct(productId);
  const { addToCart, isAddingToCart } = useShoppingCart();
  const { isFavorite, toggleFavorite } = useFavorites(productId);
  const { reviews, isLoadingReviews } = useProductReviews(productId);

  // Combine results if needed
  return {
    product,
    reviews,
    isLoadingReviews,
    isFavorite,
    toggleFavorite,
    addToCart,
    isAddingToCart
  };
}

// Example of one of the smaller hooks
function useProduct(productId) {
  const [product, setProduct] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;
    setIsLoading(true);

    fetchProduct(productId)
      .then(data => {
        if (isMounted) {
          setProduct(data);
          setIsLoading(false);
        }
      })
      .catch(err => {
        if (isMounted) {
          setError(err);
          setIsLoading(false);
        }
      });

    return () => { isMounted = false; };
  }, [productId]);

  return { product, isLoading, error };
}
Enter fullscreen mode Exit fullscreen mode

This compositional approach has made my hooks more testable and reusable. I can mix and match them like building blocks, avoiding the monolithic custom hooks that plagued my earlier React projects.

Custom Equality Functions

Default equality checks don't always align with application needs. I've implemented specialized equality functions to prevent unnecessary re-renders.

function useDeepCompareMemo(value, dependencies) {
  const ref = useRef();

  // Only update if deep comparison shows changes
  const areEqual = ref.current ? isEqual(dependencies, ref.current) : false;

  useEffect(() => {
    if (!areEqual) {
      ref.current = dependencies;
    }
  });

  return useMemo(() => value, [areEqual]);
}

// Implementation example
function useFetchWithCache(url, options, deps = []) {
  return useDeepCompareMemo(() => {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      let isMounted = true;
      fetchData(url, options)
        .then(result => {
          if (isMounted) setData(result);
        })
        .finally(() => {
          if (isMounted) setLoading(false);
        });

      return () => { isMounted = false; };
    }, [url, options]);

    return [data, loading];
  }, [url, options, ...deps]);
}
Enter fullscreen mode Exit fullscreen mode

For data-heavy applications, I often implement specialized comparison logic that understands my domain objects. This prevents wasteful re-renders when the meaningful data hasn't changed, despite reference changes.

Extraction and Testing

Testing hooks directly can be challenging. I've adopted a pattern of extracting pure business logic from hooks to enhance testability.

// Extract pure calculation logic
export function calculatePagination(totalItems, itemsPerPage, currentPage) {
  const totalPages = Math.ceil(totalItems / itemsPerPage);
  const validCurrentPage = Math.max(1, Math.min(currentPage, totalPages));

  return {
    totalPages,
    currentPage: validCurrentPage,
    hasNextPage: validCurrentPage < totalPages,
    hasPrevPage: validCurrentPage > 1
  };
}

// Use the pure function inside the hook
export function usePagination(totalItems, itemsPerPage, initialPage = 1) {
  const [currentPage, setCurrentPage] = useState(initialPage);

  // Calculate pagination using the pure function
  const pagination = useMemo(() => 
    calculatePagination(totalItems, itemsPerPage, currentPage),
    [totalItems, itemsPerPage, currentPage]
  );

  const goToPage = useCallback((page) => {
    setCurrentPage(Math.max(1, Math.min(page, pagination.totalPages)));
  }, [pagination.totalPages]);

  const nextPage = useCallback(() => {
    if (pagination.hasNextPage) {
      setCurrentPage(p => p + 1);
    }
  }, [pagination.hasNextPage]);

  const prevPage = useCallback(() => {
    if (pagination.hasPrevPage) {
      setCurrentPage(p => p - 1);
    }
  }, [pagination.hasPrevPage]);

  return {
    ...pagination,
    goToPage,
    nextPage,
    prevPage
  };
}

// Now calculatePagination can be easily tested independently
Enter fullscreen mode Exit fullscreen mode

This separation has significantly improved my test coverage. I can test the pure functions thoroughly with simple unit tests, while using React Testing Library for the hook's integration with React lifecycle.

Practical Example: A Complete Hook

Here's a comprehensive example that applies these techniques to create a robust, efficient search hook:

// Pure function for filtering
function filterItems(items, searchTerm, filterFn) {
  if (!searchTerm.trim()) return items;
  return items.filter(item => filterFn(item, searchTerm));
}

// Custom hook implementation
function useSearch(items, options = {}) {
  // Default options with memoization
  const config = useMemo(() => ({
    initialSearchTerm: '',
    debounceTime: 300,
    filterFn: (item, term) => 
      String(item).toLowerCase().includes(term.toLowerCase()),
    ...options
  }), [options]);

  // State with proper initialization
  const [searchTerm, setSearchTerm] = useState(config.initialSearchTerm);
  const [debouncedTerm, setDebouncedTerm] = useState(searchTerm);

  // Debounce effect
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, config.debounceTime);

    return () => {
      clearTimeout(handler);
    };
  }, [searchTerm, config.debounceTime]);

  // Memoized filtered results
  const filteredItems = useMemo(() => 
    filterItems(items, debouncedTerm, config.filterFn),
    [items, debouncedTerm, config.filterFn]
  );

  // Memoized stats
  const stats = useMemo(() => ({
    total: items.length,
    filtered: filteredItems.length,
    searching: debouncedTerm !== searchTerm
  }), [items.length, filteredItems.length, debouncedTerm, searchTerm]);

  // Event handler with useCallback
  const handleSearchChange = useCallback((e) => {
    setSearchTerm(typeof e === 'string' ? e : e.target.value);
  }, []);

  return {
    searchTerm,
    results: filteredItems,
    stats,
    handleSearchChange,
    resetSearch: useCallback(() => setSearchTerm(''), [])
  };
}
Enter fullscreen mode Exit fullscreen mode

This hook showcases clean dependency management, proper state initialization, composition, and extraction of pure logic. It's efficient, maintainable, and suitable for production applications.

Final Thoughts

Creating efficient custom React hooks requires attention to detail and a solid understanding of React's rendering behavior. The techniques I've shared come from real-world experiences building production applications.

Custom hooks have transformed how I organize React code. They've helped me extract and reuse logic across components while maintaining clean, readable code. By applying these six techniques, I've eliminated common performance issues and created more robust applications.

When building your own custom hooks, remember that simplicity often leads to better performance. Start with focused, single-purpose hooks and compose them as needed. Test your hooks thoroughly, especially edge cases around cleanup and initialization.

These patterns have served me well across projects of various sizes and complexities. I'm confident they'll help you create more efficient, maintainable React applications too.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)

ACI image

ACI.dev: Best Open-Source Composio Alternative (AI Agent Tooling)

100% open-source tool-use platform (backend, dev portal, integration library, SDK/MCP) that connects your AI agents to 600+ tools with multi-tenant auth, granular permissions, and access through direct function calling or a unified MCP server.

Star our GitHub!