React and TypeScript

•12 min read
TypeScriptReactTypes

Sample Image

Introduction

TypeScript has become the de-facto standard for React projects. Strict typing helps catch errors at the compilation stage, makes the code self-documenting, and significantly improves the Developer Experience thanks to autocomplete and code navigation.

In this article, we'll break down all key aspects of typing in React from basic props to advanced generic components and polymorphic patterns.

Basics of Typing in React

Typing Props

Props are the foundation of typing React components. Use type or interface:

type ButtonProps = {
  label: string;
  variant?: 'primary' | 'secondary';  // union literal
  disabled?: boolean;
  onClick: () => void;
};
 
const Button = ({ label, variant = 'primary', onClick }: ButtonProps) => (
  <button className={variant} onClick={onClick}>{label}</button>
);

The difference between interface and type: in practice, they are interchangeable for props. interface is more convenient if you need extension via extends, while type is for unions (|) and intersections (&). Most teams choose one approach and stick to it.

Generics - for generic components:

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

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

Discriminated unions - when a set of props depends on a variant:

type AlertProps =
  | { variant: "simple"; message: string }
  | { variant: "withAction"; message: string; onAction: () => void };

Types of children

React provides several types for children, each with a different degree of strictness:

TypeAcceptsWhen to use
React.ReactNodeStrings, numbers, elements, null, arraysIn most cases
React.ReactElementOnly JSX elementsStrict control
(data: T) => ReactNodeFunction (render props)render props pattern

Events

React provides typed handlers for all DOM events:

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};
 
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
};
 
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { };

Pattern: React.[EventType]Event<HTML[Element]Element> - universal format for events.

Typing Hooks

useState

TypeScript automatically infers the type from the initial value. Specify the type explicitly when the initial value does not reveal the full type:

const [count, setCount] = useState(0);              // number
const [user, setUser] = useState<User | null>(null); // explicit type

useRef

Two main scenarios: DOM references and mutable values:

const inputRef = useRef<HTMLInputElement>(null);  // DOM
const timerRef = useRef<number>(0);               // mutable value

useReducer

Discriminated union for actions is the key pattern:

type State = { count: number; loading: boolean };
type Action =
  | { type: 'increment' }
  | { type: 'set'; payload: number };
 
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 };
    case 'set': return { ...state, count: action.payload };
  }
};

useMemo and useCallback

Types are inferred automatically. Specify the type of callback arguments:

const doubled = useMemo(() => count * 2, [count]);
const handleClick = useCallback((id: string) => { /* ... */ }, []);

Custom Hooks

The most interesting part is here - typing makes the hook API clear:

// Simple hook
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle] as const;
  //                       ^^^^^^^^ without as const, the type will be (boolean | () => void)[]
  //                                with it — readonly [boolean, () => void]
}

// Hook with a generic
function useLocalStorage<T>(key: string, fallback: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? (JSON.parse(stored) as T) : fallback;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Usage — the type is inferred from fallback
const [theme, setTheme] = useLocalStorage("theme", "dark");
//     ^string  ^(value: string) => void

Type-safe Context

The main problem with Context in TypeScript is the initial value. There are two approaches, but the first one is recommended:

Approach A: undefined + custom hook (recommended)

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
const useTheme = () => {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx; // type without undefined
};

Universal Context Factory

Gets rid of repetitive boilerplate:

function createStrictContext<T>(name: string) {
  const Context = createContext<T | undefined>(undefined);
  const useStrictContext = (): T => {
    const ctx = useContext(Context);
    if (ctx === undefined)
      throw new Error(`use${name} must be used within ${name}Provider`);
    return ctx;
  };
  return [Context.Provider, useStrictContext] as const;
}
 
// Usage
const [AuthProvider, useAuth] = createStrictContext<AuthContext>('Auth');

Important: Never use {} as ContextType - it hides runtime errors.

Generic components

Generics allow you to create reusable components with type inference:

type SelectProps<T> = {
  items: T[];
  selected: T;
  getLabel: (item: T) => string;
  onChange: (item: T) => void;
};
 
const Select = <T,>({ items, selected, getLabel, onChange }: SelectProps<T>) => (
  <select value={String(selected)}
    onChange={(e) => onChange(items[e.target.selectedIndex])}
  >
    {items.map((item, i) => (
      <option key={i}>{getLabel(item)}</option>
    ))}
  </select>
);

When used, TypeScript itself will infer type T:

<Select
  items={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}
  selected={{ id: 1, name: 'Alice' }}
  getLabel={(u) => u.name}   // u is typed automatically
  onChange={(u) => console.log(u.id)}
/>

Comma after T: The syntax <T,> is needed in .tsx files so that the parser can distinguish a generic from a JSX tag.

Restricting generic via extends.

When a component should work not with any type, but only with those that have certain fields:

interface HasId {
  id: string | number;
}

interface DataListProps<T extends HasId> {
  items: T[];
  onSelect: (item: T) => void;
  renderItem: (item: T) => React.ReactNode;
}

function DataList<T extends HasId>({ items, onSelect, renderItem }: DataListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onSelect(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}

// Works — User has id
interface User { id: number; name: string; }
<DataList
  items={users}
  onSelect={(user) => console.log(user.name)} // user is typed as User
  renderItem={(user) => <span>{user.name}</span>}
/>

// Compilation error — { title: string } has no id
<DataList items={[{ title: "oops" }]} ... />

Generic + forwardRef

forwardRef complicates the syntax because it does not support generics directly. Workaround:

// Method 1: Type casting via as
const GenericList = forwardRef(function GenericList<T>(
  props: ListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return <ul ref={ref}>{/* ... */}</ul>;
}) as <T>(props: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }) => React.ReactElement;

// Method 2 (easier): Move ref to prop
interface ListProps<T> {
  items: T[];
  listRef?: React.Ref<HTMLUListElement>;
}

function List<T>({ items, listRef }: ListProps<T>) {
  return <ul ref={listRef}>{/* ... */}</ul>;
}

Generic components are justified when the same UI pattern is applied to different data types: lists, tables, selects, forms, modals with different contents. If a component operates with only one type - a generic is overkill, a regular props interface will be simpler and clearer.

Why to give up on React.FC

Type React.FC (aka React.FunctionComponent) has long been the standard way to type components. However, the React community has come to a consensus: you shouldn't use it.

Here are the main reasons:

Problems with React.FC • Implicit addition of children (before React 18). Any component accepted children even if it shouldn't, hiding bugs. • Inability to use generics. The React.FC syntax does not allow parameterizing a component with type T. • Incorrect work with defaultProps. TypeScript could not infer types from defaultProps when using React.FC. • Redundant noise in code. React.FC gives nothing useful, it only lengthens the component definition. • Limitation of the return type. React.FC forces ReactElement | null, although a component can return a string, number, etc.

What to use instead of React.FC Type props directly via the function argument:

// āŒ Before
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};
 
// āœ… After
const Button = ({ label, onClick }: ButtonProps) => {
  return <button onClick={onClick}>{label}</button>;
};

If a component needs children, simply specify it explicitly in the props type:

type CardProps = {
  title: string;
  children: React.ReactNode;
};
 
const Card = ({ title, children }: CardProps) => (
  <div><h2>{title}</h2>{children}</div>
);

Tip: Explicitly specifying children makes the component API transparent and prevents accidentally passing child elements to where they are not expected.

Extending HTML elements

For wrapper components over native elements, use ComponentProps:

type InputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> & {
  label: string;
  error?: string;
  size?: 'sm' | 'md' | 'lg';
};
 
const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, size = 'md', className, ...rest }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} className={`input-${size} ${className ?? ''}`} {...rest} />
      {error && <span className="error">{error}</span>}
    </div>
  ),
);
UtilityDescription
ComponentPropsWithRef<'div'>All div props + ref
ComponentPropsWithoutRef<'div'>All div props without ref
ComponentProps<typeof MyComp>Extract props of a component

Polymorphic components (as-prop)

A pattern where a component can be rendered as any HTML element, with automatic adjustment of allowed props:

type BoxProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children'>;
 
const Box = <T extends React.ElementType = 'div'>({
  as, children, ...rest
}: BoxProps<T>) => {
  const Component = as || 'div';
  return <Component {...rest}>{children}</Component>;
};
 
// Types adjust automatically:
<Box as="a" href="/home">Link</Box>       // href available
<Box as="button" onClick={() => {}}>Btn</Box> // onClick available

Best practice

Strict tsconfig

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Key option: noUncheckedIndexedAccess makes arr[0] type T | undefined instead of T - prevents runtime errors.

as const instead of enum

// āŒ enum — problems with tree-shaking, redundant runtime code
enum Status { Idle = 'idle', Loading = 'loading' }
 
// āœ… as const + type inference
const STATUS = {
  Idle: 'idle',
  Loading: 'loading',
  Error: 'error',
} as const;
 
type Status = (typeof STATUS)[keyof typeof STATUS];
// 'idle' | 'loading' | 'error'

Discriminated Unions for states

Instead of a set of boolean flags, describe each state as a separate union variant:

// āŒ Bad — impossible combinations are allowed
type BadState = {
  loading: boolean;
  error: string | null;
  data: User[] | null;
};
 
// āœ… Good — each state is accurately described
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };

Exhaustive check

Guarantees that when adding a new variant to a union, the compiler will point to all unhandled cases:

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}
 
const getLabel = (status: Status): string => {
  switch (status) {
    case 'idle': return 'Ready';
    case 'loading': return 'Loading...';
    case 'error': return 'Failed';
    default: return assertNever(status); // error if we forgot a case
  }
};

Template Literal Types for APIs

A powerful pattern for typing HTTP clients:

type ApiEndpoints = {
  'GET /users': { response: User[] };
  'POST /users': { response: User; body: CreateUserDto };
  'DELETE /users/:id': { response: void; params: { id: string } };
};
 
async function api<E extends keyof ApiEndpoints>(
  endpoint: E,
  ...args: ApiEndpoints[E] extends { body: infer B } ? [body: B] : []
): Promise<ApiEndpoints[E]['response']> { /* ... */ }
 
const users = await api('GET /users');  // User[]

unknown instead of any

// āŒ any disables checks — errors slip through
function parse(input: any) {
  return input.name.toUpperCase(); // no errors, runtime crash
}

// āœ… unknown forces a check prior to use
function parse(input: unknown) {
  if (typeof input === "object" && input !== null && "name" in input) {
    return (input as { name: string }).name.toUpperCase();
  }
  throw new Error("Invalid input");
}

satisfies - checking without losing the literal type

// āŒ annotation widens the type
const routes: Record<string, { path: string }> = {
  home: { path: "/" },
  about: { path: "/about" },
};
routes.typo; // no error — key is string

// āœ… satisfies checks structure, but keeps exact keys
const routes = {
  home: { path: "/" },
  about: { path: "/about" },
} satisfies Record<string, { path: string }>;

routes.typo; // compilation error
routes.home.path; // type is "/"

Extending HTML elements via ComponentPropsWithoutRef

// āŒ Manually duplicating all button props
interface ButtonProps {
  variant: "primary" | "secondary";
  disabled?: boolean;
  onClick?: () => void;
  // ... another 40 props?
}

// āœ… Inherit from HTML and add our own
type ButtonProps = {
  variant: "primary" | "secondary";
} & React.ComponentPropsWithoutRef<"button">;

function Button({ variant, className, ...rest }: ButtonProps) {
  return <button className={`btn-${variant} ${className ?? ""}`} {...rest} />;
}

Cheat sheet: Do & Don't

āœ… DoāŒ Avoid
strict: true in tsconfigDisabling strict
as const objectsenum
Discriminated unions for states{ loading: boolean; data: T | null }
Zod / Valibot for runtime validationBlindly trusting as T from API
type for propsReact.FC
ComponentPropsWithoutRef<'div'>Manually listing native props
satisfies for type check without wideningas casting without necessity
assertNever in switchIgnoring default
noUncheckedIndexedAccessarr[0] without undefined check

Conclusion

Typing is a design tool. When you describe a type, you formulate a contract: what a component accepts, what it returns, what states are possible. It forces you to think through the architecture before writing logic, not after.

Good types catch errors before running, help IDEs suggest and document component contracts without redundant comments. Start with a strict tsconfig, describe states through unions, validate external data at the boundaries - and trust type inference where TypeScript manages on its own.