Typing Custom Hooks

Difficulty: Advanced

Custom hooks are the primary mechanism for reusing stateful logic in React, and typing them correctly in TypeScript is crucial for a good developer experience. A custom hook is simply a function whose name starts with 'use', and it can call other hooks. The return type of a custom hook determines what the consumer gets - it can be a single value, an object, a tuple, or even a generic type.

The most common return type patterns for custom hooks are tuples and objects. Tuples like `[value, setValue]` follow the useState convention and allow destructuring with custom names. However, TypeScript infers arrays, not tuples, by default. You must use `as const` or explicitly type the return as `[Type1, Type2]` to get a tuple type. Objects like `{ data, loading, error }` are better when the hook returns many values, as they are self-documenting and order-independent.

Generic custom hooks accept type parameters that flow through to their return types, creating highly reusable logic. For example, a `useLocalStorage<T>` hook can store any type of value while preserving type safety. The generic parameter constrains both the initial value and the returned state. You can also add constraints to generics, like `<T extends string | number>`, to limit what types can be used with the hook.

Advanced patterns include overloaded hook signatures (where the return type changes based on the arguments), conditional return types, and hooks that return discriminated unions. These patterns are common in production codebases and are frequent interview topics. Understanding how to type a hook that returns different shapes based on its configuration options demonstrates deep TypeScript proficiency.

Code examples

Tuple vs Object Return Types

// Tuple return - must use 'as const' or explicit tuple type
function useToggle(initial: boolean): [boolean, () => void] {
  let value = initial;
  const toggle = (): void => {
    value = !value;
  };
  return [value, toggle]; // TypeScript infers [boolean, () => void]
}

// Object return - self-documenting, order-independent
function useCounter(initial: number): { count: number; increment: () => number; decrement: () => number; reset: () => number } {
  let count = initial;
  return {
    count,
    increment: () => ++count,
    decrement: () => --count,
    reset: () => { count = initial; return count; }
  };
}

const [isOpen, toggle] = useToggle(false);
console.log(`isOpen: ${isOpen}`);

const counter = useCounter(10);
console.log(`Count: ${counter.count}`);
console.log(`After increment: ${counter.increment()}`);
console.log(`After increment: ${counter.increment()}`);
console.log(`After decrement: ${counter.decrement()}`);
console.log(`After reset: ${counter.reset()}`);

Tuple returns must be explicitly typed to prevent TypeScript from inferring a union array. Object returns are better for hooks with many return values.

Generic Custom Hook

// Generic useLocalStorage hook simulation
function useStorage<T>(key: string, initialValue: T): {
  get: () => T;
  set: (value: T) => void;
  remove: () => void;
} {
  const store = new Map<string, string>();
  store.set(key, JSON.stringify(initialValue));

  return {
    get(): T {
      const item = store.get(key);
      return item ? JSON.parse(item) : initialValue;
    },
    set(value: T): void {
      store.set(key, JSON.stringify(value));
    },
    remove(): void {
      store.delete(key);
    }
  };
}

// Type-safe for different types
const settings = useStorage<{ theme: string; fontSize: number }>('settings', { theme: 'dark', fontSize: 14 });
console.log(JSON.stringify(settings.get()));
settings.set({ theme: 'light', fontSize: 16 });
console.log(JSON.stringify(settings.get()));

const score = useStorage<number>('score', 0);
console.log(score.get());
score.set(100);
console.log(score.get());

The generic type T flows through the entire hook - the initial value, getter return type, and setter parameter are all constrained to T.

Hook with Discriminated Union Return

// Hook that returns different shapes based on state
type FetchResult<T> =
  | { status: 'idle'; data: null; error: null }
  | { status: 'loading'; data: null; error: null }
  | { status: 'success'; data: T; error: null }
  | { status: 'error'; data: null; error: string };

function useFetch<T>(url: string, shouldFetch: boolean): FetchResult<T> {
  if (!shouldFetch) {
    return { status: 'idle', data: null, error: null };
  }
  // Simulate different outcomes based on URL
  if (url.includes('error')) {
    return { status: 'error', data: null, error: 'Network error' };
  }
  if (url.includes('users')) {
    return { status: 'success', data: ['Alice', 'Bob'] as unknown as T, error: null };
  }
  return { status: 'loading', data: null, error: null };
}

const result1 = useFetch<string[]>('/api/users', true);
if (result1.status === 'success') {
  console.log(`Users: ${result1.data.join(', ')}`);
}

const result2 = useFetch<any>('/api/error', true);
if (result2.status === 'error') {
  console.log(`Error: ${result2.error}`);
}

const result3 = useFetch<any>('/api/data', false);
console.log(`Idle status: ${result3.status}`);

Returning a discriminated union forces consumers to check the status before accessing data or error, preventing runtime null errors.

Key points

Concepts covered

custom hook return types, generic hooks, tuple return types, overloaded hooks, hook type inference