Skip to content

TypeScript with React

Function components are typed by annotating their props parameter. The return type is inferred by React’s type system and should not be annotated explicitly.

interface GreetingProps {
name: string;
age?: number;
}
function Greeting({ name, age }: GreetingProps) {
return (
<div>
<h1>Hello, {name}</h1>
{age !== undefined && <p>Age: {age}</p>}
</div>
);
}

The FC (FunctionComponent) type provides a default typing for function components, including an Implicit children prop:

import type { FC } from 'react';
interface CardProps {
title: string;
}
const Card: FC<CardProps> = ({ title, children }) => {
return (
<div className="card">
<h2>{title}</h2>
{children}
</div>
);
};

Common Pitfall: Using FC is controversial in modern React. The FC type implicitly includes children in the props, which encourages passing children via the props object rather than as a Prop. Many teams prefer explicit typing without FC:

interface CardProps {
title: string;
children: React.ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
{children}
</div>
);
}
ApproachchildrenProsCons
FC<Props>ImplicitConcise, includes ref typingImplicit children, deprecated in React 18 types
(props: Props) => JSX.ElementExplicit if declaredFull control, modern conventionMust type return explicitly
(props: Props) => React.ReactElementExplicit if declaredPrecise return typeDoes not allow null or string returns
function Component(props: Props)Explicit if declaredReadable, supports overloadsVerbose

ComponentPropsWithRef and ComponentPropsWithoutRef

Section titled “ComponentPropsWithRef and ComponentPropsWithoutRef”

Extract the props type from an existing component:

import type { ComponentPropsWithoutRef } from 'react';
function EnhancedButton(props: ComponentPropsWithoutRef<'button'>) {
return <button className="enhanced" {...props} />;
}

ComponentPropsWithoutRef<"button"> produces the props type of the native <button> element, Including all HTML attributes but excluding ref. Use ComponentPropsWithRef to include ref.

TypeDescription
React.ReactNodeAnything React can render: elements, strings, numbers, null``undefinedBooleans, arrays, portals
React.ReactElementA React element (result of createElement or JSX)
JSX.ElementAlias for React.ReactElement in the current JSX namespace

For component return types and children props, React.ReactNode is the most permissive and Generally correct choice.

React defines synthetic event types that wrap native browser events:

import type { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent, FocusEvent } from 'react';
function Input() {
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log(e.clientX, e.clientY);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('Form submitted');
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
console.log('Enter pressed');
}
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
<button onClick={handleClick}>Click</button>
</form>
);
}
Event TypeTarget Element
MouseEvent<T><div>``<button>``<a>Etc.
ChangeEvent<T><input>``<select>``<textarea>
FormEvent<T><form>
KeyboardEvent<T><input>``<textarea>
FocusEvent<T><input>``<textarea>``<button>
DragEvent<T>Any draggable element
WheelEvent<T>Any element
ClipboardEvent<T>Any element

When using inline handlers, the event type is inferred from context:

function Example() {
return <button onClick={(e) => console.log(e.currentTarget.textContent)}>Click me</button>;
}

The type of e is inferred as MouseEvent<HTMLButtonElement>.

useState infers the state type from the initial value:

function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [items, setItems] = useState<string[]>([]);
}

When the initial value is null or undefinedProvide an explicit type parameter:

const [user, setUser] = useState<User | null>(null);
const [data, setData] = useState<ApiResponse | undefined>(undefined);

Common Pitfall: Using useState(null) without a type parameter produces useState<null>Which Means the state can only ever be null. Always provide the union type: useState<Type | null>(null).

interface State {
count: number;
step: number;
}
type Action =
| { type: "increment'' }
| { type: "decrement' }
| { type: "setStep''; payload: number }
| { type: "reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.payload };
case 'reset':
return { count: 0, step: 1 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>Count: {state.count}</p>
<p>Step: {state.step}</p>
<button onClick={() => dispatch({ type: "increment'' })}>+</button>
<button onClick={() => dispatch({ type: "decrement' })}>-</button>
<button onClick={() => dispatch({ type: "setStep'', payload: 5 })}>Step 5</button>
<button onClick={() => dispatch({ type: "reset' })}>Reset</button>
</div>
);
}

useRef has two principal use cases, each with a different typing pattern:

Mutable ref (not attached to a DOM element):

const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countRef = useRef<number>(0);

DOM ref:

function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
</div>
);
}

Common Pitfall: useRef without an initial value argument creates MutableRefObject<T | undefined>. When the initial value is null and the ref is attached to a DOM Element, the type should be useRef<T>(null):

const ref = useRef<HTMLDivElement>(null);

useCallback preserves the function’s type:

function List() {
const [items, setItems] = useState<string[]>([]);
const addItem = useCallback((item: string) => {
setItems((prev) => [...prev, item]);
}, []);
const removeItem = useCallback((index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
}, []);
return (
<div>
{items.map((item, i) => (
<div key={i}>
{item}
<button onClick={() => removeItem(i)}>Remove</button>
</div>
))}
<button onClick={() => addItem('new')}>Add</button>
</div>
);
}
function ExpensiveComponent({ items }: { items: number[] }) {
const sorted = useMemo(() => [...items].sort((a, b) => a - b), [items]);
const total = useMemo(() => items.reduce((sum, n) => sum + n, 0), [items]);
return (
<div>
<p>Sorted: {sorted.join(', ')}</p>
<p>Total: {total}</p>
</div>
);
}

Custom hooks are functions prefixed with use that may call other hooks. Their types follow Standard function typing rules.

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
});
const setValue = (value: T) => {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
}
function Settings() {
const [theme, setTheme] = useLocalStorage<string>('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage<number>('fontSize', 16);
return (
<div>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : "light'')}>Toggle theme</button>
<p>Current theme: {theme}</p>
<input
type="range"
min={12}
max={24}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
);
}
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = (await response.json()) as T;
setData(json);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const { data, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data) return null;
return (
<ul>
{data.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}
import { createContext, useContext } from 'react';
interface ThemeContextValue {
theme: "light'' | "dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : "light''));
};
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
style={{
background: theme === "light' ? '#fff' : "#333'',
color: theme === "light' ? '#000' : "#fff'',
}}
onClick={toggleTheme}
>
Current: {theme}
</button>
);
}
const ConfigContext = createContext({
apiUrl: "/api',
debug: false,
});
function useConfig() {
return useContext(ConfigContext);
}

When a default value is provided, useContext never returns nullSo the null-check in the hook Is unnecessary. However, this approach is less type-safe because the default value may not match the Actual provider value.

For contexts where a default value is impractical (e.g., because the value depends on state), use a Factory function:

function createContextFactory<T>() {
const Context = createContext<T | null>(null);
function useContextValue(): T {
const context = useContext(Context);
if (context === null) {
throw new Error('Context must be used within its provider');
}
return context;
}
return { Context, useContext: useContextValue };
}
const { Context: AuthContext, useContext: useAuth } = createContextFactory<{
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}>();

forwardRef allows a component to expose a ref to its parent:

import { forwardRef, useImperativeHandle, useRef } from 'react';
interface InputProps {
label: string;
defaultValue?: string;
}
export interface InputHandle {
focus: () => void;
getValue: () => string;
clear: () => void;
}
const Input = forwardRef<InputHandle, InputProps>(({ label, defaultValue = '' }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
getValue: () => inputRef.current?.value ?? '',
clear: () => {
if (inputRef.current) {
inputRef.current.value = '';
}
},
}));
return (
<div>
<label>{label}</label>
<input ref={inputRef} defaultValue={defaultValue} />
</div>
);
});
Input.displayName = 'Input';
function Form() {
const nameRef = useRef<InputHandle>(null);
const handleSubmit = () => {
const value = nameRef.current?.getValue();
console.log('Submitted: ", value);
nameRef.current?.clear();
};
return (
<div>
<Input ref={nameRef} label="Name" />
<button onClick={handleSubmit}>Submit</button>
</div>
);
}

Common Pitfall: forwardRef is a generic function with two type parameters: the ref handle type And the props type. The order is <HandleType, PropsType>. Forgetting the generic parameters Produces unknown for the ref type.

TypeDescription
RefObject<T>Immutable ref object (current is readonly)
MutableRefObject<T>Mutable ref object (current is writable)
Ref<T>RefObject<T> | ((instance: T) => void) | null
const immutableRef = useRef<HTMLDivElement>(null);

immutableRef has type RefObject<HTMLDivElement>. Its current property is HTMLDivElement | null and is readonly.

const mutableRef = useRef<number>(0);

mutableRef has type MutableRefObject<number>. Its current property is number and is Writable.

function TextInput() {
const setRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus();
}
}, []);
return <input ref={setRef} />;
}
interface LayoutProps {
children: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
function Layout({ children, header, footer }: LayoutProps) {
return (
<div>
{header && <header>{header}</header>}
<main>{children}</main>
{footer && <footer>{footer}</footer>}
</div>
);
}

React provides a utility type for adding children to props:

import type { PropsWithChildren } from ''react";
type PanelProps = PropsWithChildren<{
title: string;
isOpen: boolean;
}>;
function Panel({ title, isOpen, children }: PanelProps) {
if (!isOpen) return null;
return (
<div className="panel">
<h2>{title}</h2>
{children}
</div>
);
}

To restrict the type of children (e.g., only Button components):

import type { ReactElement } from 'react';
interface ButtonGroupProps {
children: ReactElement<typeof Button>[];
}
function ButtonGroup({ children }: ButtonGroupProps) {
return <div className="button-group">{children}</div>;
}

Typing Props with Intersection Types and Generics

Section titled “Typing Props with Intersection Types and Generics”
interface BaseProps {
className?: string;
style?: React.CSSProperties;
'data-testid'?: string;
}
interface ButtonProps extends BaseProps {
variant: "primary'' | "secondary' | 'danger';
size: "sm'' | "md' | 'lg';
disabled?: boolean;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
function Button({ variant, size, disabled, onClick, children, className, style }: ButtonProps) {
return (
<button
className={`${variant} ${size} ${className ?? ''}`}
style={style}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
import type { ComponentPropsWithoutRef, ElementType } from 'react';
interface PolymorphicProps<T extends ElementType> {
as?: T;
children: React.ReactNode;
}
type Props<T extends ElementType> = PolymorphicProps<T> &
Omit<ComponentPropsWithoutRef<T>, keyof PolymorphicProps<T>>;
function Text<T extends ElementType = 'span'>({ as, children, ...rest }: Props<T>) {
const Component = as ?? 'span';
return <Component {...rest}>{children}</Component>;
}
function Example() {
return (
<div>
<Text as="h1">Heading</Text>
<Text as="p">Paragraph</Text>
<Text as="a" href="https://example.com/home">
Link
</Text>
<Text>Default span</Text>
</div>
);
}

Using MouseEvent instead of React.MouseEvent causes type errors:

const handleClick = (e: MouseEvent) => {};

This types e as the native DOM MouseEventNot React’s synthetic event. Use React.MouseEvent<HTMLButtonElement>.

Pitfall 2: useRef for DOM Elements Without null Initial Value

Section titled “Pitfall 2: useRef for DOM Elements Without null Initial Value”
const ref = useRef<HTMLDivElement>();

This creates MutableRefObject<HTMLDivElement | undefined>Which is not compatible with JSX ref Props that expect Ref<HTMLDivElement>. Always initialise DOM refs with null:

const ref = useRef<HTMLDivElement>(null);

Pitfall 3: Missing Exhaustive Checks in Reducers

Section titled “Pitfall 3: Missing Exhaustive Checks in Reducers”

When using useReducerAlways add a default case that returns the current state or calls assertNever to ensure new action types are handled:

function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
default:
return assertNever(action);
}
}

Using JSX.Element instead of React.ReactNode for children excludes valid React render values Like strings, numbers, nullAnd arrays:

interface Bad {
children: JSX.Element;
}
interface Good {
children: React.ReactNode;
}

This topic covers the core concepts of typescript with react, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • type annotations and interfaces
  • generics and utility types
  • async/await and Promises
  • modules and namespaces
  • type guards and narrowing

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

TypeScript and React form a powerful combination where types serve as live documentation for your component API. Props interfaces define the contract for each component, while hooks like useState and useRef infer types from initial values. Event handlers use generic types parameterized by the target element, ensuring you only access valid properties. Custom hooks leverage generics to create reusable, type-safe abstractions that work across different data types.

  • [[typescript/typescript]] - TypeScript fundamentals
  • [[typescript/classes]] - Component lifecycle and class components
  • [[typescript/generics]] - Generic hooks and polymorphic components
  • [[typescript/functions]] - Callback typing and event handler patterns