Post

TypeScript Patterns for React

TypeScript Patterns for React

Series Overview

  1. TypeScript Patterns for React (this post)
  2. Type-Safe Routing with TanStack Router
  3. Server State with TanStack Query
  4. Styling with Tailwind CSS v4 & shadcn/ui
  5. Modular Feature Architecture
  6. Deploy to AWS with S3, CloudFront & CDK

Introduction

TypeScript and React are a powerful combination, but writing truly type-safe React code requires more than just adding .tsx to your file extensions. This post covers the patterns, conventions, and practical techniques that make TypeScript work for you in a React codebase rather than against you.

We will walk through component typing, props patterns, discriminated unions, generic components, hooks, event handling, context, custom hooks, utility types, API response typing, performance patterns, and file naming conventions. Each section includes real code examples you can adapt to your own projects.

Component Typing

Function Components: The Standard Pattern

Use regular function declarations with an inline props type. The React.FC type is no longer recommended because it implicitly includes children, does not support generics cleanly, and obscures the return type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Preferred: explicit props, clear return
interface UserCardProps {
  name: string;
  email: string;
  avatarUrl?: string;
}

function UserCard({ name, email, avatarUrl }: UserCardProps) {
  return (
    <div>
      {avatarUrl && <img src={avatarUrl} alt={name} />}
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  );
}

Children as an Explicit Prop

1
2
3
4
5
6
7
8
9
10
11
12
13
interface PageContainerProps {
  title: string;
  children: React.ReactNode;
}

function PageContainer({ title, children }: PageContainerProps) {
  return (
    <main>
      <h1>{title}</h1>
      {children}
    </main>
  );
}

Use React.ReactNode for children that can be anything renderable (elements, strings, numbers, fragments, null). Use React.ReactElement when you specifically need a JSX element.

Props Patterns

Extending HTML Element Props

When wrapping a native element, extend its props so consumers get autocomplete for all standard attributes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { ComponentProps } from 'react';

interface ButtonProps extends ComponentProps<'button'> {
  variant?: 'primary' | 'secondary' | 'ghost';
  isLoading?: boolean;
}

function Button({
  variant = 'primary',
  isLoading,
  disabled,
  children,
  className,
  ...rest
}: ButtonProps) {
  return (
    <button
      disabled={disabled || isLoading}
      className={cn(
        'px-4 py-2 rounded font-medium',
        variant === 'primary' && 'bg-primary text-primary-foreground',
        variant === 'secondary' && 'bg-secondary text-secondary-foreground',
        variant === 'ghost' && 'bg-transparent hover:bg-accent',
        className,
      )}
      {...rest}
    >
      {isLoading ? <Spinner /> : children}
    </button>
  );
}

ComponentProps<'button'> gives you onClick, type, disabled, aria-*, and every other valid <button> attribute – fully typed.

Spreading Props to Wrapped Components

You can also extend the props of third-party or internal components:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { ComponentProps } from 'react';
import { Input } from '@/components/ui/input';

interface SearchInputProps extends ComponentProps<typeof Input> {
  onSearch: (query: string) => void;
}

function SearchInput({ onSearch, onChange, ...rest }: SearchInputProps) {
  return (
    <Input
      onChange={(e) => {
        onChange?.(e);
        onSearch(e.target.value);
      }}
      {...rest}
    />
  );
}

Optional vs Required Props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface NotificationProps {
  message: string;              // Required
  type?: 'info' | 'error';     // Optional with default
  onDismiss?: () => void;       // Optional callback
}

function Notification({
  message,
  type = 'info',
  onDismiss,
}: NotificationProps) {
  return (
    <div className={type === 'error' ? 'bg-destructive' : 'bg-muted'}>
      <p>{message}</p>
      {onDismiss && <button onClick={onDismiss}>Dismiss</button>}
    </div>
  );
}

Discriminated Unions: Conditional Props

When certain props should only exist based on the value of another prop, use a discriminated union instead of making everything optional. This prevents invalid combinations at compile time.

The Problem with Optional Props

1
2
3
4
5
6
7
// Bad: nothing prevents passing cardNumber with method="paypal"
interface PaymentProps {
  method: 'credit-card' | 'paypal';
  cardNumber?: string;
  cvv?: string;
  email?: string;
}

The Solution: Discriminated Union

1
2
3
4
5
6
7
8
9
10
11
12
13
// TypeScript enforces valid combinations
type PaymentProps =
  | { method: 'credit-card'; cardNumber: string; cvv: string }
  | { method: 'paypal'; email: string };

function PaymentForm(props: PaymentProps) {
  switch (props.method) {
    case 'credit-card':
      return <CreditCardForm card={props.cardNumber} cvv={props.cvv} />;
    case 'paypal':
      return <PayPalForm email={props.email} />;
  }
}

Async State Modeling

Discriminated unions are also excellent for modeling async state:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function UserProfile({ state }: { state: AsyncState<User> }) {
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <div>{state.data.name}</div>;
    case 'error':
      return <div>Error: {state.error.message}</div>;
  }
}

Mutually Exclusive Props Without a Discriminant

1
2
3
4
5
6
7
8
type ExclusiveProps<T, U> =
  | (T & { [K in keyof U]?: never })
  | (U & { [K in keyof T]?: never });

type IconButtonProps = ExclusiveProps<
  { icon: string; 'aria-label': string },
  { children: React.ReactNode }
>;

Generic Components

Use generics when a component works with any data type but needs to preserve type information for consumers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
interface SelectOption<T> {
  value: T;
  label: string;
}

interface SelectProps<T> {
  options: SelectOption<T>[];
  value: T;
  onChange: (value: T) => void;
  placeholder?: string;
}

function Select<T extends string | number>({
  options,
  value,
  onChange,
  placeholder,
}: SelectProps<T>) {
  return (
    <select
      value={String(value)}
      onChange={(e) => {
        const selected = options.find((o) => String(o.value) === e.target.value);
        if (selected) onChange(selected.value);
      }}
    >
      {placeholder && <option value="">{placeholder}</option>}
      {options.map((option) => (
        <option key={String(option.value)} value={String(option.value)}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

When used, the generic type is inferred automatically:

1
2
3
4
5
6
7
8
9
<Select
  options={[
    { value: 'active' as const, label: 'Active' },
    { value: 'inactive' as const, label: 'Inactive' },
    { value: 'pending' as const, label: 'Pending' },
  ]}
  value={status}
  onChange={(val) => setStatus(val)}  // val is 'active' | 'inactive' | 'pending'
/>

Generic List Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) {
  if (items.length === 0) {
    return <p className="text-muted-foreground">{emptyMessage ?? 'No items'}</p>;
  }
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

Hooks Typing

useState

TypeScript infers the type from the initial value. Specify the generic when the initial value does not contain all possible types:

1
2
3
const [name, setName] = useState('');                    // Inferred as string
const [user, setUser] = useState<User | null>(null);     // Explicit generic
const [items, setItems] = useState<Item[]>([]);           // Array that starts empty

useReducer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type 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 };
  }
}

useRef

1
2
3
4
5
// DOM ref: starts as null, React manages the value
const inputRef = useRef<HTMLInputElement>(null);

// Mutable ref: stores a value between renders
const intervalRef = useRef<number | null>(null);

The distinction: useRef<T>(null) returns RefObject<T> (read-only .current). useRef<T | null>(null) returns MutableRefObject<T | null> (writable .current).

Event Handling

Common Event Types

HandlerEvent Type
onClickReact.MouseEvent<HTMLButtonElement>
onChange (input)React.ChangeEvent<HTMLInputElement>
onChange (select)React.ChangeEvent<HTMLSelectElement>
onSubmitReact.FormEvent<HTMLFormElement>
onKeyDownReact.KeyboardEvent<HTMLInputElement>
onFocus / onBlurReact.FocusEvent<HTMLInputElement>

Form Events Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function LoginForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const email = formData.get('email') as string;
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" onChange={handleChange} />
      <button type="submit">Submit</button>
    </form>
  );
}

Context with Type Safety

The standard pattern uses an undefined default combined with a guard in the hook, ensuring you never silently get undefined:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { createContext, useContext, useState, type ReactNode } from 'react';

interface AuthContextValue {
  user: User | null;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}

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

function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = async (credentials: LoginCredentials) => {
    const user = await loginApi(credentials);
    setUser(user);
  };

  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value=>
      {children}
    </AuthContext.Provider>
  );
}

export { AuthProvider, useAuth };

Custom Hooks

Naming and Return Type Conventions

Always prefix hooks with use. Choose the return shape based on complexity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Single value: return directly
function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);
  // ... effect logic
  return matches;
}

// Value + setter pair: return a tuple (like useState)
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [stored, setStored] = useState<T>(() => {
    const item = window.localStorage.getItem(key);
    return item ? (JSON.parse(item) as T) : initialValue;
  });

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

  return [stored, setValue];
}

// Complex state: return an object (named properties)
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  return { value, toggle, setTrue, setFalse } as const;
}

Using as const on tuple returns preserves literal types, which is especially important when returning from hooks.

Type Utilities

Common Utility Types for React

1
2
3
4
5
6
7
type ButtonSize = Pick<ButtonProps, 'size'>;
type CardBodyProps = Omit<CardProps, 'header' | 'footer'>;
type PartialUser = Partial<User>;
type RequiredConfig = Required<AppConfig>;
type InputProps = ComponentProps<typeof Input>;
type QueryResult = ReturnType<typeof useQuery>;
type UserData = Awaited<ReturnType<typeof fetchUser>>;

Branded Types for Domain Safety

Branded types prevent accidentally passing one kind of identifier where another is expected:

1
2
3
4
5
6
7
8
9
10
11
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

function fetchUser(id: UserId): Promise<User> { ... }
function fetchPost(id: PostId): Promise<Post> { ... }

const userId = '123' as UserId;
const postId = '456' as PostId;

fetchUser(userId);   // OK
fetchUser(postId);   // Type error

API Response Typing

Typed API Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
}

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

async function apiGet<T>(endpoint: string): Promise<ApiResponse<T>> {
  const response = await fetch(`/api${endpoint}`);
  if (!response.ok) throw new ApiError(response.status, await response.text());
  return response.json();
}

Error Types and Type Guards

1
2
3
4
5
6
7
8
9
10
11
12
13
class ApiError extends Error {
  constructor(
    public status: number,
    public body: string,
  ) {
    super(`API Error ${status}: ${body}`);
    this.name = 'ApiError';
  }
}

function isApiError(error: unknown): error is ApiError {
  return error instanceof ApiError;
}

Performance Patterns

React.memo and useCallback

Use React.memo to skip re-renders for unchanged props, and useCallback to stabilize function references passed to memoized children:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const ExpensiveList = React.memo(function ExpensiveList({
  items,
  onSelect,
}: ExpensiveListProps) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onSelect(item)}>{item.name}</li>
      ))}
    </ul>
  );
});

function ParentComponent() {
  const handleSelect = useCallback((item: Item) => {
    console.log(item.name);
  }, []);

  return <ExpensiveList items={items} onSelect={handleSelect} />;
}

When NOT to Memoize

Do not wrap everything in useMemo or useCallback. The overhead of memoization itself is not free. Use them when you are passing callbacks to memoized children, computing expensive derived values, or creating objects/arrays used in dependency arrays. Skip them for simple primitives, event handlers on leaf DOM elements, or values that change on every render anyway.

File and Naming Conventions

ConventionExampleWhen
PascalCase file = PascalCase componentUserCard.tsxAlways for components
camelCase for hooksuseAuth.tsAlways for hooks
camelCase for utilitiesformatDate.tsAlways for utils
Suffix props with PropsUserCardPropsAlways
Suffix context values with ContextValueAuthContextValueAlways
interface for objectsinterface User { ... }Object shapes
type for unions/intersectionstype Status = 'active' \| 'inactive'Unions, computed types

Import Organization

Keep imports consistent: React first, then third-party libraries, then internal aliases, then feature-local imports, then styles. Use the type keyword for type-only imports to help bundlers tree-shake.

1
2
3
4
5
import { useState, useCallback, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { UserCard } from './UserCard';
import type { User } from '../types/user.types';

Strict TypeScript Configuration

Enable strict mode and additional safety checks in your tsconfig.json:

1
2
3
4
5
6
7
8
9
10
11
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  }
}

"strict": true enables strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, noImplicitThis, and alwaysStrict. Never disable these individually.

Anti-Patterns to Avoid

Anti-PatternWhyInstead
any typeDisables type checkingUse unknown, generics, or specific types
as type assertionBypasses the type systemUse type guards or discriminated unions
Optional props for conditional behaviorAllows invalid statesUse discriminated unions
useEffect for data fetchingMissing caching, race conditionsUse TanStack Query
Prop drilling through many levelsBrittle, hard to refactorUse Context or state management
// @ts-ignoreHides real type errorsFix the underlying type issue
Memoizing everythingAdds overhead without benefitMemoize only when profiling shows a problem

Conclusion

TypeScript in React is not about adding types after the fact – it is about designing your component APIs so that the compiler catches mistakes before they reach production. The patterns covered here (explicit props, discriminated unions, generics, typed context, and strict configuration) form a solid foundation for any React TypeScript codebase.

In the next post, we will build on these TypeScript foundations by exploring type-safe routing with TanStack Router, where every path, parameter, and search query is validated at compile time.

References

This post is licensed under CC BY 4.0 by the author.