DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

React + TypeScript: The Patterns That Actually Matter

You don't need to know every exotic TypeScript pattern to be productive with React. You need to know the patterns you'll use every single day.

After three years of writing React with TypeScript across multiple production apps, I've identified the patterns that account for 95% of what you actually write. Master these, and you'll be productive. The other 5%? You can Google when you need it.

This is the guide I wish I'd had when I started.

Pattern 1: Typing Props

This is where everyone starts, and it's the most important pattern to get right.

Functional Component Props

JavaScript:

function Button({ label, onClick, disabled }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

TypeScript:

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

function Button({ label, onClick, disabled }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

With Children

The Problem: What type is children?

// ❌ Wrong - too restrictive
interface CardProps {
  children: string;
}

// ❌ Wrong - too loose
interface CardProps {
  children: any;
}

// ✅ Correct - accepts any valid React content
interface CardProps {
  children: React.ReactNode;
}

function Card({ children }: CardProps) {
  return <div className="card">{children}</div>;
}
Enter fullscreen mode Exit fullscreen mode

When to use each children type:

  • React.ReactNode - Any renderable content (most common)
  • React.ReactElement - Only React elements (not strings/numbers)
  • (props: T) => React.ReactElement - Render function pattern

Optional Props and Defaults

interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
}

function Button({ 
  label, 
  variant = 'primary',
  size = 'medium' 
}: ButtonProps) {
  return (
    <button className={`btn-${variant} btn-${size}`}>
      {label}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Props with Specific String Literals

// ✅ Good - limits to specific values
interface AlertProps {
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
}

// ❌ Bad - any string is allowed
interface AlertProps {
  type: string;
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Typing State

Simple State

JavaScript:

const [count, setCount] = useState(0);
const [name, setName] = useState('');
Enter fullscreen mode Exit fullscreen mode

TypeScript:

// Type is inferred automatically
const [count, setCount] = useState(0);  // number
const [name, setName] = useState('');   // string
Enter fullscreen mode Exit fullscreen mode

When TypeScript can infer the type, let it! Don't over-annotate.

State with Objects

interface User {
  id: string;
  name: string;
  email: string;
}

// ✅ Explicit type when initial value doesn't match
const [user, setUser] = useState<User | null>(null);

// Later:
setUser({ id: '1', name: 'John', email: 'john@example.com' });
Enter fullscreen mode Exit fullscreen mode

State with Arrays

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

// ✅ Explicit type for empty array
const [todos, setTodos] = useState<Todo[]>([]);

// ✅ Type is inferred when initialized with data
const [todos, setTodos] = useState([
  { id: '1', text: 'Learn TypeScript', completed: false }
]);  // Todo[] is inferred
Enter fullscreen mode Exit fullscreen mode

Complex State Patterns

// Union type for state
type LoadingState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

const [state, setState] = useState<LoadingState>({ status: 'idle' });

// TypeScript knows which properties exist based on status
if (state.status === 'success') {
  console.log(state.data);  // ✓ data exists here
}

if (state.status === 'error') {
  console.log(state.error);  // ✓ error exists here
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Typing Events

This is where TypeScript can feel frustrating if you don't know the patterns.

Click Events

// ✅ Method 1: Inline function (type is inferred)
<button onClick={(e) => console.log(e.currentTarget.value)}>
  Click me
</button>

// ✅ Method 2: Separate function with explicit type
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  console.log(e.currentTarget);
}

<button onClick={handleClick}>Click me</button>
Enter fullscreen mode Exit fullscreen mode

Form Events

// Input change
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  console.log(e.target.value);
}

<input onChange={handleChange} />

// Textarea change
function handleTextChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
  console.log(e.target.value);
}

<textarea onChange={handleTextChange} />

// Select change
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
  console.log(e.target.value);
}

<select onChange={handleSelectChange}>...</select>
Enter fullscreen mode Exit fullscreen mode

Form Submit

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
  // Process form data
}

<form onSubmit={handleSubmit}>...</form>
Enter fullscreen mode Exit fullscreen mode

The Pattern to Remember

React.[EventType]<HTML[ElementType]>
Enter fullscreen mode Exit fullscreen mode

Common combinations:

  • React.MouseEvent<HTMLButtonElement> - Button clicks
  • React.ChangeEvent<HTMLInputElement> - Input changes
  • React.FormEvent<HTMLFormElement> - Form submits
  • React.KeyboardEvent<HTMLInputElement> - Keyboard events
  • React.FocusEvent<HTMLInputElement> - Focus/blur events

Pattern 4: Typing Refs

Refs are tricky because they can point to DOM elements or hold mutable values.

DOM Element Refs

// ✅ Correct - specify the element type
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  // TypeScript knows this might be null
  inputRef.current?.focus();
}, []);

<input ref={inputRef} />
Enter fullscreen mode Exit fullscreen mode

Common element types:

  • HTMLInputElement - <input>
  • HTMLTextAreaElement - <textarea>
  • HTMLDivElement - <div>
  • HTMLButtonElement - <button>
  • HTMLFormElement - <form>

Mutable Value Refs

// Storing a mutable value (not a DOM element)
const renderCount = useRef<number>(0);

useEffect(() => {
  renderCount.current += 1;
});
Enter fullscreen mode Exit fullscreen mode

Refs with Initial Value

// ✅ When you have an initial value, null is not needed
const timerRef = useRef<number>(0);

// Later
timerRef.current = setTimeout(() => {}, 1000);
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Typing Context

Context requires a bit more setup, but the pattern is consistent.

Basic Context

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

// Create context with undefined default
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// Provider component
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>
  );
}

// Custom hook for consuming context
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

// Usage
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why This Pattern?

  1. Context might be undefined before provider mounts
  2. Custom hook throws error if used outside provider
  3. Consumer components get full type safety

Pattern 6: Typing Custom Hooks

Custom hooks follow the same patterns as regular hooks.

Simple Custom Hook

function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

// Usage
const { count, increment } = useCounter(10);
Enter fullscreen mode Exit fullscreen mode

Hook with Generics

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

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

  return [value, setStoredValue] as const;
}

// Usage - T is inferred from initialValue
const [user, setUser] = useLocalStorage('user', { name: '', age: 0 });
Enter fullscreen mode Exit fullscreen mode

Hook Returning Tuple vs Object

// Tuple - for when order matters (like useState)
function useTuple() {
  return [value, setValue] as const;
}
const [val, setVal] = useTuple();

// Object - for better naming and optional destructuring
function useObject() {
  return { value, setValue, reset };
}
const { value, reset } = useObject();  // Can skip setValue
Enter fullscreen mode Exit fullscreen mode

Pro tip: Return an object unless you're mimicking built-in hooks like useState.

Pattern 7: Typing useReducer

useReducer is powerful but requires careful typing.

Basic Pattern

// 1. Define state type
interface State {
  count: number;
  error: string | null;
}

// 2. Define action types using discriminated union
type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset'; payload: number }
  | { type: 'error'; payload: string };

// 3. Create reducer
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'decrement':
      return { ...state, count: state.count - 1 };
    case 'reset':
      // TypeScript knows payload exists for 'reset'
      return { ...state, count: action.payload };
    case 'error':
      // TypeScript knows payload exists for 'error'
      return { ...state, error: action.payload };
    default:
      return state;
  }
}

// 4. Use the reducer
function Counter() {
  const [state, dispatch] = useReducer(reducer, { 
    count: 0, 
    error: null 
  });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why Discriminated Unions?

// ❌ Without discriminated union
type Action = {
  type: 'increment' | 'decrement' | 'reset';
  payload?: number;  // Always optional, no safety
};

// TypeScript can't verify payload exists when needed
case 'reset':
  return { ...state, count: action.payload };  // Could be undefined!

// ✅ With discriminated union
type Action =
  | { type: 'increment' }
  | { type: 'reset'; payload: number };  // payload required for reset

case 'reset':
  return { ...state, count: action.payload };  // Guaranteed to exist
Enter fullscreen mode Exit fullscreen mode

Pattern 8: Typing Component Props with HTML Attributes

Sometimes you want your component to accept all the same props as a native element.

Extending Native Props

// ✅ Extend button props
interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant: 'primary' | 'secondary';
}

function CustomButton({ variant, ...props }: CustomButtonProps) {
  return <button {...props} className={`btn-${variant}`} />;
}

// Now you can use it like a regular button with extra features
<CustomButton 
  variant="primary" 
  onClick={handleClick}
  disabled={isLoading}
  aria-label="Submit form"
/>
Enter fullscreen mode Exit fullscreen mode

Common attribute types:

  • React.ButtonHTMLAttributes<HTMLButtonElement>
  • React.InputHTMLAttributes<HTMLInputElement>
  • React.HTMLAttributes<HTMLDivElement> - Generic div/span/etc
  • React.AnchorHTMLAttributes<HTMLAnchorElement>

Omitting Props

// Remove onClick from the allowed props
interface CustomButtonProps 
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
  onPress: () => void;  // Use onPress instead
}
Enter fullscreen mode Exit fullscreen mode

Pattern 9: Children Patterns

Different ways to type children for different use cases.

Any Renderable Content

interface CardProps {
  children: React.ReactNode;  // Most common
}
Enter fullscreen mode Exit fullscreen mode

Single React Element

interface WrapperProps {
  children: React.ReactElement;  // Must be a single element
}

// ✅ Works
<Wrapper><div>Hello</div></Wrapper>

// ❌ Error - string is not ReactElement
<Wrapper>Hello</Wrapper>

// ❌ Error - multiple elements
<Wrapper>
  <div>One</div>
  <div>Two</div>
</Wrapper>
Enter fullscreen mode Exit fullscreen mode

Render Function

interface ListProps<T> {
  items: T[];
  children: (item: T, index: number) => React.ReactNode;
}

function List<T>({ items, children }: ListProps<T>) {
  return (
    <div>
      {items.map((item, index) => (
        <div key={index}>{children(item, index)}</div>
      ))}
    </div>
  );
}

// Usage
<List items={users}>
  {(user, index) => <div>{user.name}</div>}
</List>
Enter fullscreen mode Exit fullscreen mode

Specific Component Type

interface TabsProps {
  children: React.ReactElement<TabProps> | React.ReactElement<TabProps>[];
}
Enter fullscreen mode Exit fullscreen mode

Pattern 10: Typing Async Functions

Handling async operations in components.

Async Event Handlers

// ✅ Correct - mark function as async
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();

  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    setData(data);
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

useEffect with Async

// ❌ Wrong - can't make useEffect callback async
useEffect(async () => {
  const data = await fetchData();
}, []);

// ✅ Correct - define async function inside
useEffect(() => {
  async function loadData() {
    const data = await fetchData();
    setData(data);
  }

  loadData();
}, []);

// ✅ Also correct - IIFE
useEffect(() => {
  (async () => {
    const data = await fetchData();
    setData(data);
  })();
}, []);
Enter fullscreen mode Exit fullscreen mode

Common Mistakes and How to Fix Them

Mistake 1: Over-using any

// ❌ Defeats the purpose of TypeScript
function handleClick(e: any) { }

// ✅ Use the right type
function handleClick(e: React.MouseEvent<HTMLButtonElement>) { }
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Not Typing Event Handlers

// ❌ No type safety
const handleChange = (e) => { };

// ✅ Type the parameter
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { };
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Forgetting to Handle Null with Refs

const inputRef = useRef<HTMLInputElement>(null);

// ❌ Could be null
inputRef.current.focus();

// ✅ Check for null
inputRef.current?.focus();

// ✅ Or check explicitly
if (inputRef.current) {
  inputRef.current.focus();
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Not Using Discriminated Unions for State

// ❌ Inconsistent state possible
interface State {
  loading: boolean;
  data: User | null;
  error: Error | null;
}

// ✅ Only valid combinations possible
type State =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };
Enter fullscreen mode Exit fullscreen mode

Quick Reference Cheat Sheet

// Props
interface Props {
  children: React.ReactNode;
}

// State
const [value, setValue] = useState<Type>(initialValue);

// Events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {};

// Refs
const ref = useRef<HTMLDivElement>(null);

// Context
const MyContext = createContext<Type | undefined>(undefined);

// Custom Hooks
function useCustom<T>(param: string): [T, (val: T) => void] {}

// Reducer
type Action = { type: 'action'; payload?: Type };
const reducer = (state: State, action: Action): State => {};

// Extending HTML
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
Enter fullscreen mode Exit fullscreen mode

The 95% Rule

These patterns cover 95% of what you'll write in React + TypeScript:

  • Props (with and without children)
  • State (simple and complex)
  • Events (click, change, submit)
  • Refs (DOM elements)
  • Context (with custom hook)
  • Custom hooks (returning objects or tuples)
  • useReducer (with discriminated unions)
  • Extending HTML attributes
  • Async operations

Master these, and you'll be productive. The exotic patterns? Google them when you need them.

One Last Tip

Let TypeScript infer when it can. Don't over-annotate:

// ❌ Unnecessary - TypeScript knows this is a number
const [count, setCount] = useState<number>(0);

// ✅ Let it infer
const [count, setCount] = useState(0);

// ✅ Only annotate when needed
const [user, setUser] = useState<User | null>(null);
Enter fullscreen mode Exit fullscreen mode

TypeScript is smartest when you trust its inference. Add types only when you need to:

  1. Override inference (like null initial state)
  2. Define the shape (like component props)
  3. Add constraints (like event handler types)

These patterns are your foundation. Build on them as needed, but come back to them often—they're your 95%.

Top comments (0)