Skip to content
Home
TypeScript with React: Typing Components, Hooks, and Event Handlers

TypeScript with React: Typing Components, Hooks, and Event Handlers

TypeScript TypeScript 7 min read 1462 words Beginner ExcellentWiki Editorial Team

TypeScript and React are a dominant combination in modern frontend development. According to the State of JS 2024 survey, over 70% of React developers use TypeScript professionally. TypeScript catches prop mismatches, incorrect state updates, and event handler type errors at compile time — bugs that would otherwise surface as confusing runtime failures. Microsoft’s React TypeScript guide and the React TypeScript Cheatsheet (see React TypeScript Cheatsheet) provide canonical patterns. This guide covers typing components, hooks, event handlers, context, and advanced patterns.

Typing Functional Components

Functional components accept props as their first argument. Type the props object with an interface:

interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  onClick: () => void;
---

const Button: React.FC<ButtonProps> = ({ label, variant = 'primary', disabled, onClick }) => {
  return (
    <button className={`btn btn-${variant}`} disabled={disabled} onClick={onClick}>
      {label}
    </button>
  );
---;

Using React.FC (FunctionComponent) is optional. Many TypeScript teams prefer omitting it for simpler inference:

const Button = ({ label, variant = 'primary', disabled, onClick }: ButtonProps) => {
  // ...
---;

The explicit return type JSX.Element can be added but is usually inferred.

Typing Children

React’s React.PropsWithChildren or React.ReactNode types children:

interface CardProps {
  title: string;
  children: React.ReactNode;
---

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

Use React.ReactNode for flexible children. Use React.ReactElement when you need to enforce exactly one child element.

Typing Hooks

useState

TypeScript infers the type from the initial value. For explicit typing, pass the type parameter:

const [count, setCount] = useState(0);                         // inferred: number
const [user, setUser] = useState<User | null>(null);           // explicit union
const [items, setItems] = useState<Item[]>([]);                // generic array

When the initial value differs from the eventual type (e.g., null before data loads), provide the union type explicitly.

useRef

useRef has two overloads. For mutable references to DOM elements:

const inputRef = useRef<HTMLInputElement>(null!);

useEffect(() => {
  inputRef.current.focus(); // safe with null! assertion
---, []);

For mutable values that persist across renders:

const intervalRef = useRef<number | undefined>(undefined);

const start = () => {
  intervalRef.current = window.setInterval(tick, 1000);
---;

Typing useCallback and useMemo

TypeScript infers return types for useCallback and useMemo from their factory functions, so explicit type annotations are rarely needed. However, the dependency array must be correct — install eslint-plugin-react-hooks with the exhaustive-deps rule to verify it:

const handleSubmit = useCallback(
  (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit(values);
  },
  [values, onSubmit],
);

const sortedItems = useMemo(
  () => items.sort((a, b) => a.name.localeCompare(b.name)),
  [items],
);

Omitting a dependency from the array produces a lint warning. Adding an unnecessary dependency causes unnecessary recomputation. The ESLint rule catches both cases, ensuring the callback/memoized value stays in sync with its closure without over-invalidating.

useReducer

Typing reducers with discriminated unions provides exhaustive type-checking:

type Action =
  | { type: 'increment'; payload: number }
  | { type: 'decrement'; payload: number }
  | { type: 'reset' };

type State = { count: number };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'increment': return { count: state.count + action.payload };
    case 'decrement': return { count: state.count - action.payload };
    case 'reset': return { count: 0 };
    default: return state;
  }
---;

const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: 'increment', payload: 1 }); // fully typed
dispatch({ type: 'reset' });                  // no payload required

Typing Event Handlers

React provides synthetic event types that wrap native DOM events for cross-browser consistency.

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setValue(e.target.value);
---;

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  // submit logic
---;

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.clientX, e.clientY);
---;

Common event types: ChangeEvent, FormEvent, MouseEvent, KeyboardEvent, FocusEvent, TouchEvent. Each is generic over the element type.

Typing Context

React Context is typed through the generic createContext:

interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
---

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const useAuth = (): AuthContextType => {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
---;

The createContext default value should be undefined (or a sensible default) to detect usage outside the provider. The useAuth hook provides a typed accessor with a helpful error message.

Typing Custom Hooks with Generics

Custom hooks that manage state across components benefit from generic parameters. A typed useLocalStorage hook provides compile-time guarantees that serialize/deserialize operations stay consistent:

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

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

  return [storedValue, setValue];
---

// Usage — fully typed
const [theme, setTheme] = useLocalStorage('theme', 'light');
// theme: string, setTheme: (value: string) => void

Without generics, this hook would return [any, Function], requiring callers to re-assert types. The generic version propagates the type from the initial value to the setter and storage value.

Typing Higher-Order Components

Typing HOCs requires careful handling of props and generics:

interface WithLoadingProps {
  loading: boolean;
---

function withLoading<T extends object>(Component: React.ComponentType<T>) {
  return (props: T & WithLoadingProps) => {
    return props.loading ? <Spinner /> : <Component {...props} />;
  };
---

The generic parameter T captures the wrapped component’s props, ensuring that withLoading(UserCard) produces a component requiring UserCard’s props plus loading.

Typing Refs and Forwarded Refs

React.forwardRef requires explicit typing for ref and props:

interface FancyInputProps {
  label: string;
---

const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
  ({ label }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} />
    </div>
  )
);

FAQ

Should I use React.FC or not?
The React team deprecated React.FC in React 18’s type definitions for several reasons: it implicitly adds children, it does not work with generics, and it complicates default props. Most teams now omit React.FC and type props directly.

How do I type the children prop when it must be a single element?
Use React.ReactElement instead of React.ReactNode. ReactNode includes strings, numbers, and fragments; ReactElement restricts to a single React element.

How do I type async event handlers?
Typing the return value as Promise<void> or adding a void return is correct: onClick={async (e: React.MouseEvent) => { ... }}. The event type is inferred if you inline the handler.

What is the best way to type API response state in React?
Use discriminated unions: type ApiState<T> = { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: string }. This eliminates impossible states and provides exhaustive type-checking in render functions.

How do I type useEffect dependencies?
TypeScript’s useEffect accepts a callback and a dependency array. The dependency array is not type-checked by default — install eslint-plugin-react-hooks with the exhaustive-deps rule to verify correctness. This rule is the standard for React TypeScript projects and is included in Create React App, Next.js, and Vite templates.

How do I type a generic React component?
Use a generic type parameter on the component function:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
---

function List<T>({ items, renderItem }: ListProps<T>) {
  return <>{items.map(renderItem)}</>;
---

TypeScript infers T from the items prop in JSX usage: <List items={users} renderItem={(u) => <div>{u.name}</div>} /> infers T as User.

How do I merge props from a parent component?
Use intersection types or interface extension:

type ExtendedProps = OriginalProps & { extra: string };
// or
interface ExtendedProps extends OriginalProps { extra: string; }

Both approaches produce the same result, but interface extension provides better tooltip display in IDEs.

How do I type createContext with a default value of null?
Use a union with null and provide a helper hook that throws if accessed outside the provider:

type AuthContext = { user: User; login: (email: string) => Promise<void> };
const ctx = createContext<AuthContext | null>(null);
export function useAuth() {
  const value = useContext(ctx);
  if (!value) throw new Error('useAuth must be used within AuthProvider');
  return value;
---

This pattern is recommended by the React team for contexts where the default is meaningless.

How do I type CSS properties in React?
React’s CSSProperties type covers all valid CSS properties:

const style: React.CSSProperties = {
  display: 'flex',
  flexDirection: 'column',
  gap: '1rem',
---;

TypeScript validates property names and value types. Invalid properties like displau: 'flex' or wrong value types like gap: 1 (missing unit string) produce compile-time errors.

Can I use decorators with React components?
TypeScript decorators (@experimentalDecorators) can be used with class components but not functional components. The React team recommends functional components with hooks, which do not support decorators. For class components in legacy code, ensure experimentalDecorators is enabled in tsconfig.

Internal Links

Internal Links

References

Section: TypeScript 1462 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top