Chapter 21

React Component Types: Generics, Hooks, Event Handlers

Why Not React.FC

React.FC (alias for React.FunctionComponent) used to be the standard way to write typed React components. The community and official React docs have moved away from it. The reasons are concrete.

// The React.FC approach
const Greeting: React.FC<{ name: string }> = ({ name }) => {
  return <div>Hello, {name}</div>;
};

React.FC had an implicit behavior: it automatically added children: React.ReactNode to the props type. Versions before React 18 kept this behavior. That meant:

// The implicit children problem with React.FC (React 17 and earlier)
const Button: React.FC<{ label: string }> = ({ label }) => (
  <button>{label}</button>
);

// This compiles even though Button was not designed to accept children
<Button label="Click me"><span>extra</span></Button>;

React 18 fixed this (React.FC no longer implicitly includes children), but the episode revealed the larger issue: React.FC provides no additional type safety. It is just a type annotation that is more verbose than writing a plain function.

Recommended: plain functions

interface GreetingProps {
  name: string;
  age?: number;
}

function Greeting({ name, age }: GreetingProps) {
  return <div>Hello, {name}{age ? `, age ${age}` : ''}</div>;
}

// Arrow function is fine too
const Greeting = ({ name, age }: GreetingProps) => (
  <div>Hello, {name}</div>
);

The return type (JSX.Element) is inferred by the compiler — no explicit annotation needed.

Props Typing: Children, Events, Refs

Typing children correctly

import { ReactNode, PropsWithChildren } from 'react';

// Option 1: declare children manually
interface CardProps {
  title: string;
  children: ReactNode;   // the most permissive children type
}

// Option 2: use the PropsWithChildren utility type
interface CardBaseProps {
  title: string;
}
type CardProps = PropsWithChildren<CardBaseProps>;
// equivalent to { title: string; children?: ReactNode }

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="content">{children}</div>
    </div>
  );
}

ReactNode covers ReactElement | string | number | boolean | null | undefined | ReactPortal — essentially anything renderable. If you need exactly one React element (no strings or numbers), use ReactElement.

Event handler types

import { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent } from 'react';

interface ButtonProps {
  onClick: (event: MouseEvent<HTMLButtonElement>) => void;
  onDoubleClick?: (event: MouseEvent<HTMLButtonElement>) => void;
  label: string;
}

interface InputProps {
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
  value: string;
}

interface FormProps {
  onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}

function SearchInput({ onChange, value }: InputProps) {
  return (
    <input
      type="text"
      value={value}
      onChange={onChange}
      // onChange type is (event: ChangeEvent<HTMLInputElement>) => void
      // which matches exactly what the input element expects
    />
  );
}

The generic T in ChangeEvent<T> is the DOM element type. event.target gets the type T, so event.target.value is only accessible when T is HTMLInputElement, HTMLTextAreaElement, or another element that has a value property.

// Common event types at a glance
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
  e.target.value;        // string
};

const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
  e.target.value;        // string (the value of the selected option)
  e.target.selectedIndex; // number
};

const handleTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
  e.target.value;        // string
};

Typing useRef

useRef has two entirely different use cases. Their type signatures differ.

Use case 1: holding a DOM reference

import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  // useRef<HTMLInputElement>(null) — initial value must be null
  // type: RefObject<HTMLInputElement> (ref.current may be null)
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // ref.current can be null here — must check
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} />;
}

// The initial value matters:
const ref1 = useRef<HTMLInputElement>(null);
// type: React.RefObject<HTMLInputElement>
// ref1.current is HTMLInputElement | null (read-only, React manages it)

const ref2 = useRef<HTMLInputElement | null>(null);
// type: React.MutableRefObject<HTMLInputElement | null>
// ref2.current is writable (you can assign to it manually)

Use case 2: holding a mutable value (without triggering re-renders)

function Timer() {
  // Store an interval ID — no re-render needed
  const intervalId = useRef<ReturnType<typeof setInterval> | null>(null);

  const start = () => {
    intervalId.current = setInterval(() => {
      console.log('tick');
    }, 1000);
  };

  const stop = () => {
    if (intervalId.current !== null) {
      clearInterval(intervalId.current);
      intervalId.current = null;
    }
  };

  return (
    <div>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

Key distinction: for DOM refs use useRef<ElementType>(null); for mutable values use useRef<ValueType>(initialValue).

useState with Complex Types

import { useState } from 'react';

// Simple types: inferred automatically, no generic needed
const [count, setCount] = useState(0);         // number
const [name, setName] = useState('');           // string
const [visible, setVisible] = useState(false);  // boolean

// Complex types: explicit generic required because the initial value
// does not carry enough information to infer the full type
interface User {
  id: string;
  name: string;
  email: string;
}

const [user, setUser] = useState<User | null>(null);
// user type: User | null
// setUser type: Dispatch<SetStateAction<User | null>>

// Array state
const [items, setItems] = useState<string[]>([]);
// Without the generic, items would be typed as never[]

// Union type state
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
// setStatus('typo') is now a compile error

// Object state
interface FormState {
  username: string;
  password: string;
  rememberMe: boolean;
}

const [form, setForm] = useState<FormState>({
  username: '',
  password: '',
  rememberMe: false,
});

const handleChange = (field: keyof FormState, value: FormState[keyof FormState]) => {
  setForm(prev => ({ ...prev, [field]: value }));
};

useReducer with Discriminated Union Actions

import { useReducer } from 'react';

interface CartState {
  items: CartItem[];
  total: number;
  isLoading: boolean;
}

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

// Discriminated union action — each action has a unique type literal
type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'SET_LOADING'; payload: boolean }
  | { type: 'CLEAR_CART' };

// The reducer: TypeScript narrows action to the specific branch type
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      // action.payload type: CartItem
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price * action.payload.quantity,
      };

    case 'REMOVE_ITEM':
      // action.payload type: { id: string }
      const filtered = state.items.filter(item => item.id !== action.payload.id);
      return {
        ...state,
        items: filtered,
        total: filtered.reduce((sum, item) => sum + item.price * item.quantity, 0),
      };

    case 'UPDATE_QUANTITY':
      // action.payload type: { id: string; quantity: number }
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ),
      };

    case 'SET_LOADING':
      // action.payload type: boolean
      return { ...state, isLoading: action.payload };

    case 'CLEAR_CART':
      // no payload
      return { items: [], total: 0, isLoading: false };

    default:
      // Exhaustiveness check: if all cases are handled, this is never
      const _exhaustive: never = action;
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [],
    total: 0,
    isLoading: false,
  });

  // dispatch argument is fully typed as CartAction
  dispatch({ type: 'ADD_ITEM', payload: { id: '1', name: 'Book', price: 29.9, quantity: 1 } });
  dispatch({ type: 'CLEAR_CART' }); // no payload needed
  // dispatch({ type: 'WRONG' }) — compile error
}

Strictly Typed useContext

import { createContext, useContext, useState, ReactNode } from 'react';

interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// Option 1: non-null assertion (acceptable when Provider is guaranteed)
const ThemeContext = createContext<ThemeContextValue>(
  null as unknown as ThemeContextValue  // forced initial value
);

// Option 2: custom hook with runtime check (safer)
const ThemeContext2 = createContext<ThemeContextValue | null>(null);

function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext2);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;  // type: ThemeContextValue (null excluded)
}

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');

  return (
    <ThemeContext2.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext2.Provider>
  );
}

function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  // theme type: 'light' | 'dark'
  return (
    <button
      onClick={toggleTheme}
      style={{ background: theme === 'dark' ? '#333' : '#fff' }}
    >
      Switch to {theme === 'dark' ? 'light' : 'dark'}
    </button>
  );
}

Custom Hooks: Return Type Inference

import { useState, useEffect, useCallback } from 'react';

// Return type inferred by the compiler
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json: T = await res.json();
      setData(json);
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // Inferred return type:
  // { data: T | null; loading: boolean; error: Error | null; refetch: () => Promise<void> }
  return { data, loading, error, refetch: fetchData };
}

// Pass the expected data type when using the hook
interface User {
  id: string;
  name: string;
}

function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
  // data type: User | null — fully type-safe
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;
  return <div>{data.name}</div>;
}

Explicit return type (better for public APIs)

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => Promise<void>;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  // ...same as above
}

Generic Components: The <T,> Syntax Quirk

Generic components let you write reusable lists, tables, and selects while preserving type safety.

import { ReactNode } from 'react';

interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

// Note the trailing comma in <T,>
// In a .tsx file, <T> is parsed as a JSX opening tag
// The comma signals: this is a generic parameter, not JSX
function List<T,>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
  if (items.length === 0) return <div>{emptyMessage}</div>;

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Type is inferred at the call site — no manual annotation
interface User {
  id: string;
  name: string;
  email: string;
}

function UserList({ users }: { users: User[] }) {
  return (
    <List
      items={users}
      keyExtractor={user => user.id}
      // item is inferred as User — no annotation needed
      renderItem={user => <span>{user.name} ({user.email})</span>}
    />
  );
}

Generic components with constraints

function SortableList<T extends { id: string; order: number }>(
  { items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }
) {
  const sorted = [...items].sort((a, b) => a.order - b.order);
  return (
    <ul>
      {sorted.map(item => <li key={item.id}>{renderItem(item)}</li>)}
    </ul>
  );
}

Alternatives to avoid the .tsx parsing ambiguity

// Use extends to remove ambiguity
function Select<T extends object>(props: SelectProps<T>) { ... }

// Or write the component in a .ts file (no JSX), import it in .tsx

Typing forwardRef

import { forwardRef, useImperativeHandle, useRef } from 'react';

interface InputProps {
  label: string;
  error?: string;
  onChange: (value: string) => void;
}

// forwardRef generics: first is the ref type, second is the props type
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, onChange }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input
          ref={ref}
          onChange={e => onChange(e.target.value)}
          aria-invalid={!!error}
        />
        {error && <span className="error">{error}</span>}
      </div>
    );
  }
);

Input.displayName = 'Input';

function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return (
    <div>
      <Input
        ref={inputRef}
        label="Username"
        onChange={value => console.log(value)}
      />
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
    </div>
  );
}

useImperativeHandle: custom exposed API

interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
  seek: (seconds: number) => void;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
  ({ src }, ref) => {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play: () => videoRef.current?.play(),
      pause: () => videoRef.current?.pause(),
      seek: (seconds) => {
        if (videoRef.current) videoRef.current.currentTime = seconds;
      },
    }));

    return <video ref={videoRef} src={src} />;
  }
);

// The ref type is VideoPlayerHandle, not HTMLVideoElement
function MoviePage() {
  const playerRef = useRef<VideoPlayerHandle>(null);
  return (
    <div>
      <VideoPlayer ref={playerRef} src="/movie.mp4" />
      <button onClick={() => playerRef.current?.play()}>Play</button>
      <button onClick={() => playerRef.current?.seek(30)}>Skip 30s</button>
    </div>
  );
}

Higher-Order Components

import { ComponentType } from 'react';

function withAuth<P extends { userId?: string }>(
  WrappedComponent: ComponentType<P>
) {
  type PublicProps = Omit<P, 'userId'>;

  return function AuthenticatedComponent(props: PublicProps) {
    const { userId } = useAuth();

    if (!userId) {
      return <div>Please log in</div>;
    }

    return <WrappedComponent {...(props as P)} userId={userId} />;
  };
}

interface DashboardProps {
  userId: string;
  title: string;
}

function Dashboard({ userId, title }: DashboardProps) {
  return <div>User {userId}: {title}</div>;
}

const AuthDashboard = withAuth(Dashboard);
// AuthDashboard only requires { title: string } — userId is injected by the HOC
<AuthDashboard title="My Dashboard" />;

Anti-Patterns

Anti-pattern Problem Correct approach
React.FC on plain components Historical baggage; implicit children before React 18 Plain function + explicit Props interface
Event types written as any Lose type info on event.target Use ChangeEvent<HTMLInputElement> etc.
useState(null) without a generic Type inferred as null; later assignments fail useState<User | null>(null)
Not checking useRef.current for null Runtime TypeError ref.current?.method() or explicit if-check
<T> generic in a .tsx file Parsed as JSX, syntax error <T,> or <T extends unknown>
No return type on hook (public API) Inferred type leaks implementation details Define an explicit interface HookResult
Using context without null check useContext returns null outside Provider Custom hook with runtime null check

Summary

Scenario Recommended approach
Component definition Plain function + Props interface
DOM ref useRef<HTMLXxxElement>(null)
Mutable value ref useRef<T>(initialValue)
Complex state useState<T>(initial) with explicit generic
Multi-action reducer Discriminated union Action + exhaustive switch
Context Custom hook + runtime null check
Generic component (.tsx) function Comp<T,> or extends syntax
Ref forwarding forwardRef<RefType, Props>

Next chapter covers full-stack type safety: how tRPC shares types between server and client without code generation, and how Prisma and Drizzle ORM derive types from your data model.

Rate this chapter
4.7  / 5  (8 ratings)

💬 Comments