Skip to content
Back to blog
  • Engineering

Building Reusable React Hooks: TypeScript Patterns That Scale

Learn how to build reusable react hooks with typescript for handling data fetching and local state cleanly in your web applications.

MMuhammad Muneeb4 min read

Writing clean frontend code often means pulling shared logic out of components. When you are building modern applications, mastering react hooks typescript patterns gives you the exact type safety and developer ergonomics you need to scale your codebase without losing your mind to unexpected runtime errors.

Too often, custom hooks are either written with loose types or burdened by excessive generic boilerplate that obfuscates what is actually happening. A well-designed hook should feel like a natural extension of the React API. Whether you are handling local state or fetching remote resources in your frontend, as when configuring a deployed app while self-hosting Next.js on a VPS with PM2 and Nginx, explicit types make your abstractions foolproof.

Designing React Hooks TypeScript Patterns for State

Let us start with a common scenario in react state management. You need a persistent local state mechanism that syncs with local storage or handles complex validation rules. Instead of repeating this state initialization logic across multiple views, you encapsulate it inside a utility function.

When building typescript custom hooks for state, inference is your friend, but explicit generic boundaries are necessary when the default value can be null or undefined. Let us write a hook that manages a toggle state with a forced boolean constraint.

useToggle.ts
import { useState, useCallback } from 'react';

export function useToggle(initialState: boolean = false): [boolean, () => void, (value: boolean) => void] {
  const [value, setValue] = useState<boolean>(initialState);

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

  const setToggleValue = useCallback((newValue: boolean) => {
    setValue(newValue);
  }, []);

  return [value, toggle, setToggleValue];
}

By returning an explicit tuple instead of an object, you allow consumers of the hook to rename the variables freely using standard array destructuring, matching the familiar pattern of the built-in useState hook.

Exposing setter functions alongside getters gives you fine-grained control over state transitions. However, you must be careful not to bypass validation logic when directly setting values from consuming components, maintaining encapsulation boundaries.

Building a Robust Data Fetching Hook

Data fetching is another area where typing pays off immediately. A generic fetch hook needs to handle loading states, error objects, and the actual response data payload without resorting to 'any'. This becomes critical when querying remote endpoints like the Shopify GraphQL API while building custom Shopify apps with the Admin GraphQL API.

When working with react hooks typescript integrations, defining interfaces for your hook parameters and return types ensures that your component consumption is completely type-safe. Here is how I structure a standard asynchronous data loader.

useFetch.ts
import { useState, useEffect } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

export function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let isMounted = true;
    const controller = new AbortController();

    setLoading(true);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) {
          throw new Error('Network response was not ok');
        }
        return res.json();
      })
      .then((json) => {
        if (isMounted) {
          setData(json);
          setError(null);
        }
      })
      .catch((err) => {
        if (err.name !== 'AbortError' && isMounted) {
          setError(err);
        }
      })
      .finally(() => {
        if (isMounted) {
          setLoading(false);
        }
      });

    return () => {
      isMounted = false;
      controller.abort();
    };
  }, [url]);

  return { data, loading, error };
}

Another common pitfall when typing data fetchers is failing to handle partial updates or pagination tokens. By defining generic metadata types alongside your primary data payload, you can construct hooks that seamlessly manage infinite scroll without losing type safety along the way.

Handling Edge Cases and Cleanup

Writing custom hooks requires thinking about component lifecycles. Notice the use of the isMounted flag and the AbortController in the fetch example above. Without these safeguards, asynchronous updates after a component unmounts will throw warnings or errors in your test suite and production console.

When your hooks manage background processes or subscriptions, always return a cleanup function. If you are offloading heavy workloads or dealing with server-side tasks, remember that architectural patterns matter just as much on the client. For slow work, hand it to background jobs with BullMQ and Redis so requests stay fast.

When dealing with race conditions in asynchronous hooks, simply ignoring unmounted state is often not enough. You should also ensure that your cancellation tokens properly invalidate stale requests, preventing out-of-order responses from overwriting newer data state.

Best Practices for Hook Architecture

To keep your custom hooks maintainable over time, follow a few simple guidelines:

  • Keep hooks focused on a single responsibility rather than trying to manage UI and business logic simultaneously.
  • Always use generic type parameters for data models so consumers can pass their specific types.
  • Avoid unnecessary re-renders by wrapping callback functions in useCallback and memoizing computed values.
  • Prefix every custom hook with 'use' to satisfy static analysis rules in linters.

Investing time into proper typing up front prevents subtle bugs from leaking into your production bundles. Once you establish these patterns, building scalable interfaces becomes systematic and reliable.

Related posts