Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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: number

When 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:

src/store/authStore.ts
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 number

useContext: 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:

src/store/hooks.ts
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.