Skip to main content

Custom React Hooks TypeScript: How to Type Generics

Custom hooks are the primary way React developers share stateful logic across components. When you add TypeScript, generics transform custom hooks from loose bundles of useState calls into reusable abstractions that callers can customize without reimplementing. The challenge is balancing flexibility with type safety.

I've seen teams struggle with custom hooks that leak internal types or fail to preserve caller-specified types through the function signature. TypeScript generics solve this elegantly when written with intention.

The Anatomy of a Typed Custom Hook

A custom hook is a JavaScript function that calls other hooks. To type it, annotate the return type and any parameters:

function useToggle(initialValue: boolean = false): {
value: boolean;
toggle: () => void;
setValue: (newValue: boolean) => void;
} {
const [value, setValue] = React.useState(initialValue);

const toggle = () => setValue((prev) => !prev);

return { value, toggle, setValue };
}

// Usage
function Modal() {
const { value: isOpen, toggle: toggleOpen } = useToggle(false);

return (
<div>
<button onClick={toggleOpen}>
{isOpen ? 'Close' : 'Open'}
</button>
{isOpen && <div>Modal content</div>}
</div>
);
}

This hook is simple but complete: the return type is explicit, so callers know exactly what the hook provides. IntelliSense guides them to value, toggle, and setValue.

Generic Hooks with Type Parameters

For hooks that work with any data type, use a generic type parameter:

function useAsync<T>(
fn: () => Promise<T>,
deps: React.DependencyList
): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);

React.useEffect(() => {
let isMounted = true;

const execute = async () => {
setLoading(true);
try {
const result = await fn();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error(String(err)));
setData(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};

execute();

return () => {
isMounted = false;
};
}, deps);

return { data, loading, error };
}

// Usage: TypeScript infers T as User
interface User {
id: number;
email: string;
name: string;
}

function UserProfile() {
const { data: user, loading, error } = useAsync<User>(
() => fetch('/api/user').then((r) => r.json()),
[]
);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;

return <div>Welcome, {user.name}</div>;
}

The generic <T> allows the caller to specify the data type, and TypeScript ensures the hook returns data of that type. No type casting needed.

Constrained Generics

Sometimes you want a generic hook that works only with specific types. Use extends to constrain the type parameter:

function usePagination<T extends { id: string | number }>(
items: T[],
itemsPerPage: number = 10
): {
currentPage: number;
totalPages: number;
currentItems: T[];
goToPage: (page: number) => void;
nextPage: () => void;
prevPage: () => void;
} {
const [currentPage, setCurrentPage] = React.useState(1);
const totalPages = Math.ceil(items.length / itemsPerPage);

const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentItems = items.slice(startIndex, endIndex);

const goToPage = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};

const nextPage = () => goToPage(currentPage + 1);
const prevPage = () => goToPage(currentPage - 1);

return {
currentPage,
totalPages,
currentItems,
goToPage,
nextPage,
prevPage,
};
}

// Usage: items must have an id property
interface Product {
id: string;
name: string;
price: number;
}

function ProductList() {
const products: Product[] = [
{ id: '1', name: 'A', price: 10 },
{ id: '2', name: 'B', price: 20 },
];

const { currentItems, nextPage, currentPage, totalPages } = usePagination(
products,
5
);

return (
<div>
{currentItems.map((product) => (
<div key={product.id}>{product.name}</div>
))}
<button onClick={nextPage} disabled={currentPage >= totalPages}>
Next
</button>
</div>
);
}

By constraining T extends { id: string | number }, you ensure the hook only works with types that have an id field. This allows the hook to use id as a React key safely.

Form Hooks with Controlled Inputs

A common use case is a hook that manages form state with validation:

interface FormErrors<T> {
[K in keyof T]?: string;
}

interface UseFormReturn<T> {
values: T;
errors: FormErrors<T>;
touched: Partial<Record<keyof T, boolean>>;
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
setFieldTouched: (field: keyof T) => void;
reset: () => void;
isValid: boolean;
}

function useForm<T extends Record<string, any>>(
initialValues: T,
onSubmit: (values: T) => void | Promise<void>,
validate?: (values: T) => FormErrors<T>
): UseFormReturn<T> {
const [values, setValues] = React.useState(initialValues);
const [errors, setErrors] = React.useState<FormErrors<T>>({});
const [touched, setTouched] = React.useState<Partial<Record<keyof T, boolean>>>({});

const setFieldValue = <K extends keyof T>(field: K, value: T[K]) => {
setValues((prev) => ({ ...prev, [field]: value }));
};

const setFieldTouched = (field: keyof T) => {
setTouched((prev) => ({ ...prev, [field]: true }));
};

const validateForm = () => {
if (!validate) return true;
const newErrors = validate(values);
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};

const isValid = Object.keys(errors).length === 0;

return {
values,
errors,
touched,
setFieldValue,
setFieldTouched,
reset,
isValid,
};
}

// Usage
interface LoginForm {
email: string;
password: string;
}

function LoginComponent() {
const form = useForm<LoginForm>(
{ email: '', password: '' },
async (values) => {
await fetch('/api/login', { method: 'POST', body: JSON.stringify(values) });
},
(values) => {
const errors: FormErrors<LoginForm> = {};
if (!values.email.includes('@')) errors.email = 'Invalid email';
if (values.password.length < 8) errors.password = 'Too short';
return errors;
}
);

return (
<form>
<input
type="email"
value={form.values.email}
onChange={(e) => form.setFieldValue('email', e.currentTarget.value)}
/>
{form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={(e) => form.setFieldValue('password', e.currentTarget.value)}
/>
{form.errors.password && <span>{form.errors.password}</span>}
</form>
);
}

This hook uses mapped types ([K in keyof T]) to ensure the errors object has the same keys as the form values, keyed by field name. TypeScript prevents typos in field names.

Local Storage Hook

A practical hook that syncs state to local storage:

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = React.useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});

const setValue = (value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(error);
}
};

return [storedValue, setValue];
}

// Usage
function Counter() {
const [count, setCount] = useLocalStorage<number>('count', 0);

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

The generic <T> ensures the stored value and setter match the initial value type.

Key Takeaways

  • Annotate custom hook return types explicitly so callers know what the hook provides.
  • Use generic type parameters to make hooks reusable across different data types.
  • Constrain generics with extends to ensure the type has required properties.
  • Mapped types ([K in keyof T]) allow hooks to create objects with keys matching the generic type.
  • Always include a cleanup or dependency array management to prevent stale closures.

Frequently Asked Questions

How do I type the dependency array in a custom hook?

Use React.DependencyList as the type. It's an alias for ReadonlyArray<any> and matches the type TypeScript expects.

Can I call custom hooks conditionally or in loops?

No. Rules of hooks apply to custom hooks too. Call custom hooks unconditionally at the top level of the component. If you need conditional behavior, move it inside the hook.

Should custom hooks be generic or should callers handle generics?

Make the hook generic if it works with multiple data types. This keeps the hook flexible and allows callers to use it without reimplementing the logic. Avoid leaking generic parameters into component props.

How do I handle errors in custom hooks?

Return an error field in the return object (like useAsync), or throw an error in useEffect using an error boundary. The former is easier for callers to handle.

Further Reading