Typing Hooks with TypeScript in React
Typing Hooks with TypeScript
~15 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
React hooks like useState are themselves GENERIC – they accept a type parameter that determines what the state may contain. Most of the time TypeScript infers this type automatically; sometimes you need to help it along.
useState: automatic type inference from the initial value
const [query, setQuery] = useState('');
// TypeScript automatically infers: query is of type 'string'
// setQuery(42) would now be a type error, without us having written anything explicit
const [count, setCount] = useState(0);
// automatically: numberWhen the initial value isn't enough: explicit type parameters
For null initial values that are meant to become something else LATER, inference isn't enough – TypeScript would assume null is forever the only possible type. Here's the typed authStore (Zustand instead of Redux Toolkit, see chapter 28) as an example:
import { create } from 'zustand';
interface User {
username: string;
}
interface AuthState {
user: User | null;
login: (username: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
login(username) {
set({ user: { username } });
},
logout() {
set({ user: null });
},
}));create<AuthState>((set) => ({{...}})) – the explicit type parameter <AuthState> BEFORE the parentheses forces Zustand to check the entire returned store state against this interface. User | null (a "union type") describes our exact use case: either a real user OR null when nobody's logged in – any code that reads user.username WITHOUT a prior null check gets flagged by TypeScript IMMEDIATELY as an error.
useRef: two different use cases, two typing patterns
// Case 1: a ref to a DOM element (like searchInputRef in ProductListPage)
// initial value MUST be null, the type describes the LATER DOM element:
const searchInputRef = useRef<HTMLInputElement>(null);
// access:
searchInputRef.current?.focus(); // ?. is MANDATORY, since .current starts as null
// Case 2: ref as a plain, mutable value store (like renderCount in RenderCounter)
// initial value is already the target type, .current is NEVER null:
const renderCount = useRef<number>(0);
renderCount.current += 1; // no ?. needed, .current is always a numberuseContext: the null-guard pattern from AuthContext
In case you wanted to use Context instead of Zustand (see chapters 27/28 for the limits discussion) – here, for completeness, the typed version of the pattern "React for Beginners" already introduced in JavaScript:
interface AuthContextValue {
user: User | null;
login: (username: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth() must be used inside <AuthProvider>.');
}
return context; // TypeScript knows here: context can no longer be null
}The if (context === null) throw ... check isn't just a runtime safety net (as already explained in "React for Beginners"), it's also a TypeScript feature called "type narrowing": AFTER this check, TypeScript knows context can only be AuthContextValue anymore, no longer AuthContextValue | null – the function's return type AuthContextValue (without | null) is therefore correct, without any extra cast.
useSelector/useDispatch: using the types prepared last chapter
Remember RootState/AppDispatch from store/index.ts (chapter 40)? Now they get used – for TYPE-SAFE Redux hooks, Redux Toolkit recommends creating YOUR OWN, pre-configured versions of useSelector/useDispatch:
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;From now on, useAppSelector/useAppDispatch get used EVERYWHERE in the project instead of the "bare" useSelector/useDispatch from react-redux – the selector parameter (state in (state) => state.cart.items) automatically gets the correct RootState type this way, without you having to annotate it manually every time:
// Before (in CartWidget.jsx, without type safety):
const itemCount = useSelector((state) => state.cart.items.length);
// After (in CartWidget.tsx, state is automatically recognized as RootState):
import { useAppSelector } from '../store/hooks';
const itemCount = useAppSelector((state) =>
state.cart.items.reduce((total, item) => total + item.quantity, 0)
);Tipp: import type {{ RootState, AppDispatch }} instead of a regular import – the type keyword explicitly signals that ONLY type information is being imported, which disappears entirely at compile time (see "noEmit": true from chapter 40) and causes NO actual code import at RUNTIME.