Falytom logoFALYTOM
Games
ToolsUI ComponentsServicesProductsAbout
LoginSign Up
menu
Falytom logoFALYTOM

Master web development through interactive learning and transform your business with professional services.

Learn

  • Tutorials
  • Blog
  • Games

Resources

  • Tools
  • UI Components

Company

  • Services
  • Products
  • About

© 2025 Tomilola Group. All rights reserved.

Facebook logoTwitter logoInstagram logoLinkedIn logoGitHub logoYouTube logo
Back to Blog
Web DevelopmentReactTypeScript

TypeScript Best Practices for React Developers

Master TypeScript in your React applications with these essential best practices. Learn proper typing, advanced patterns, and common pitfalls to avoid. Make your code more maintainable.

Yusuf Falade

Yusuf Falade

December 15, 2025
10 min read
TypeScript and React logos with code snippets in the background

Introduction

TypeScript has become the standard for modern React development, and for good reason. It catches bugs at compile time, provides excellent IDE support, and makes your code more maintainable and self-documenting.

However, using TypeScript effectively requires understanding its features and following best practices. In this guide, we'll explore the essential patterns and techniques that will make you a more effective React + TypeScript developer.

Why TypeScript Matters

Before diving into best practices, let's understand why TypeScript is crucial for React development:

Catch Errors Early TypeScript catches type errors before your code runs, preventing runtime crashes and reducing bugs in production.

Better IDE Support Get autocomplete, intelligent suggestions, and instant error feedback as you type.

Self-Documenting Code Types serve as inline documentation, making it easier for others (and future you) to understand your code.

Safer Refactoring Confidently refactor your code knowing TypeScript will catch any breaking changes.

#1: Properly Type Your Component Props

Always define explicit interfaces for your component props. This makes your components self-documenting and catches errors early.

❌ Bad:

function Button(props: any) {
  return <button>{props.text}</button>
}

✅ Good:

interface ButtonProps {
  text: string
  onClick: () => void
  disabled?: boolean
  variant?: 'primary' | 'secondary' | 'danger'
}

function Button({ text, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
  return (
    <button 
      onClick={onClick} 
      disabled={disabled}
      className={`btn-${variant}`}
    >
      {text}
    </button>
  )
}

Key Points:

  • Use interface for prop types
  • Mark optional props with ?
  • Provide default values in destructuring
  • Use union types for variants

#2: Avoid the 'any' Type

The any type defeats the purpose of TypeScript. Use unknown when you truly don't know the type.

❌ Bad:

function processData(data: any) {
  return data.value.toUpperCase()
}

✅ Good:

function processData(data: unknown): string {
  if (
    typeof data === 'object' && 
    data !== null && 
    'value' in data && 
    typeof data.value === 'string'
  ) {
    return data.value.toUpperCase()
  }
  throw new Error('Invalid data format')
}

#3: Use Type Guards

Type guards help TypeScript narrow down types, making your code safer and more readable.

Create Custom Type Guards:

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

interface Admin extends User {
  role: 'admin'
  permissions: string[]
}

// Type guard function
function isAdmin(user: User | Admin): user is Admin {
  return 'role' in user && user.role === 'admin'
}

// Usage
function greetUser(user: User | Admin) {
  if (isAdmin(user)) {
    // TypeScript knows user is Admin here
    console.log(`Admin ${user.name} has ${user.permissions.length} permissions`)
  } else {
    // TypeScript knows user is User here
    console.log(`Hello ${user.name}`)
  }
}

#4: Use Discriminated Unions for State

When dealing with different states (loading, success, error), use discriminated unions instead of multiple boolean flags.

❌ Bad:

interface State {
  loading: boolean
  error: string | null
  data: User[] | null
}

✅ Good:

type State = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string }

function UserList() {
  const [state, setState] = useState<State>({ status: 'idle' })

  // TypeScript enforces correct access
  if (state.status === 'success') {
    return <div>{state.data.map(user => ...)}</div>
  }

  if (state.status === 'error') {
    return <div>Error: {state.error}</div>
  }

  return <div>Loading...</div>
}

#5: Properly Type React Hooks

useState:

// Explicit type when initial value is null/undefined
const [user, setUser] = useState<User | null>(null)

// Type inference works when you have an initial value
const [count, setCount] = useState(0) // inferred as number

// Array of objects
const [users, setUsers] = useState<User[]>([])

useRef:

// For DOM elements
const inputRef = useRef<HTMLInputElement>(null)

// For mutable values
const countRef = useRef<number>(0)

// Usage
function FocusInput() {
  const inputRef = useRef<HTMLInputElement>(null)
  
  const handleFocus = () => {
    inputRef.current?.focus()
  }
  
  return <input ref={inputRef} />
}

#6: Type Event Handlers Correctly

React event types can be tricky. Here's how to handle them properly:

import { ChangeEvent, FormEvent, MouseEvent } from 'react'

interface FormData {
  email: string
  password: string
}

function LoginForm() {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: ''
  })

  // Input change handler
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target
    setFormData(prev => ({ ...prev, [name]: value }))
  }

  // Form submit handler
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    console.log(formData)
  }

  // Button click handler
  const handleReset = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()
    setFormData({ email: '', password: '' })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
      />
      <button type="submit">Login</button>
      <button onClick={handleReset}>Reset</button>
    </form>
  )
}

#7: Use Utility Types

TypeScript provides powerful utility types. Use them!

interface User {
  id: string
  name: string
  email: string
  age: number
  createdAt: Date
}

// Pick - Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>

// Omit - Exclude specific properties
type UserWithoutDates = Omit<User, 'createdAt'>

// Partial - Make all properties optional
type UpdateUser = Partial<User>

// Required - Make all properties required
type RequiredUser = Required<User>

// Readonly - Make all properties readonly
type ImmutableUser = Readonly<User>

// Record - Create an object type with specific key-value types
type UserMap = Record<string, User>

#8: Generic Components

Create reusable generic components for better type safety:

interface SelectOption<T> {
  value: T
  label: string
}

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

function Select<T>({ options, value, onChange }: SelectProps<T>) {
  return (
    <select 
      value={String(value)}
      onChange={(e) => {
        const option = options.find(
          opt => String(opt.value) === e.target.value
        )
        if (option) onChange(option.value)
      }}
    >
      {options.map((option, index) => (
        <option key={index} value={String(option.value)}>
          {option.label}
        </option>
      ))}
    </select>
  )
}

// Usage with different types
const numberOptions = [
  { value: 1, label: 'One' },
  { value: 2, label: 'Two' },
]

const stringOptions = [
  { value: 'small', label: 'Small' },
  { value: 'large', label: 'Large' },
]

function App() {
  const [num, setNum] = useState(1)
  const [size, setSize] = useState('small')

  return (
    <>
      <Select options={numberOptions} value={num} onChange={setNum} />
      <Select options={stringOptions} value={size} onChange={setSize} />
    </>
  )
}

#9: Type API Responses

Always type your API responses to ensure data integrity:

// Define response types
interface ApiResponse<T> {
  data: T
  message: string
  success: boolean
}

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

// Type-safe fetch function
async function fetchUser(userId: string): Promise<User> {
  const response = await fetch(`/api/users/${userId}`)
  
  if (!response.ok) {
    throw new Error('Failed to fetch user')
  }
  
  const json: ApiResponse<User> = await response.json()
  
  if (!json.success) {
    throw new Error(json.message)
  }
  
  return json.data
}

// Usage with proper error handling
async function loadUser(userId: string) {
  try {
    const user = await fetchUser(userId)
    console.log(user.name) // TypeScript knows user has 'name'
  } catch (error) {
    if (error instanceof Error) {
      console.error(error.message)
    }
  }
}

#10: Configure TypeScript Strictly

Use strict TypeScript settings in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Common Mistakes to Avoid

1. Type Assertions Without Validation

// ❌ Bad - Unsafe type assertion
const user = data as User

// ✅ Good - Validate first
function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'name' in data &&
    'email' in data
  )
}

if (isUser(data)) {
  // Safe to use as User
  console.log(data.name)
}

2. Forgetting Null Checks

// ❌ Bad
function getNameLength(user: User | null) {
  return user.name.length // Error if user is null
}

// ✅ Good
function getNameLength(user: User | null) {
  return user?.name.length ?? 0
}

3. Misusing Type vs Interface

Use interface for object shapes, type for unions and complex types:

// ✅ Use interface for objects
interface User {
  name: string
  age: number
}

// ✅ Use type for unions
type Status = 'loading' | 'success' | 'error'

// ✅ Use type for complex types
type ApiResponse<T> = {
  data: T
  error?: string
} | {
  data?: never
  error: string
}

TypeScript is a powerful tool that, when used correctly, dramatically improves your React development experience. By following these best practices, you'll:

  • Write more maintainable code
  • Catch bugs before they reach production
  • Get better IDE support and autocomplete
  • Make refactoring safer and easier

Found this article helpful? Kindly share them with others who might love them too!

Master Web Development: One Email at a Time!🔥

Subscribe to our newsletter for in-depth guides, project ideas, and best practices to enhance your coding journey, delivered straight to your inbox all for FREE. Your next project deserves to stand out!

Yusuf Falade

About Yusuf Falade

Trying to live in the moment while i still can

Related Articles

Next.js application development on a laptop screen with code editor

Building Your First Next.js Application: A Complete Beginner's Guide

Learn how to build modern web applications with Next.js 15. This comprehensive guide takes you from setup to deployment, covering routing, data fetching, and styling with Tailwind CSS.

You might also like

HTML Tutorials

Learn HTML from scratch with interactive lessons

UI Components

Ready-made components for your projects

Developer Tools

Boost productivity with handy generators

HTML Game

Learn HTML concepts through interactive gameplay

Join Our Web Development Training Program

Master frontend & backend development with hands-on projects.