Building Modern UIs with React and TypeScript

by Ada
Last updated on May 24, 2024

Building Modern UIs with React and TypeScript

Master the art of building type-safe React applications with TypeScript. This comprehensive guide covers everything from basic setup to advanced patterns and best practices.

Why TypeScript with React?

TypeScript brings static typing to React, catching errors at compile time rather than runtime. This leads to:

  • Better IDE support with autocomplete and IntelliSense
  • Fewer runtime errors and bugs
  • Improved code maintainability
  • Self-documenting code through type definitions
  • Safer refactoring

Setting Up a React TypeScript Project

Create a new project with Vite:

bash
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Your project structure will look like:

my-app/
├── src/
│   ├── App.tsx
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.json
└── package.json

Component Props with TypeScript

Basic Props

Define props using interfaces or types:

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

function Button({ label, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  )
}

Props with Children

Use the ReactNode type for children:

tsx
interface CardProps {
  title: string
  children: React.ReactNode
}

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="card-content">{children}</div>
    </div>
  )
}

Event Handlers

Type event handlers correctly:

tsx
interface FormProps {
  onSubmit: (data: FormData) => void
}

function MyForm({ onSubmit }: FormProps) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    onSubmit(formData)
  }

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

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

State Management with TypeScript

useState Hook

TypeScript often infers state types automatically:

tsx
function Counter() {
  // Type inferred as number
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

For complex types, use generics:

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

function UserProfile() {
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    fetchUser().then(setUser)
  }, [])

  if (!user) return <div>Loading...</div>

  return <div>Welcome, {user.name}!</div>
}

useReducer Hook

Type your reducer and actions:

tsx
type State = {
  count: number
  error: string | null
}

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'error'; payload: string }

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 'error':
      return { ...state, error: action.payload }
    default:
      return state
  }
}

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>
    </div>
  )
}

Custom Hooks with TypeScript

Create reusable, type-safe hooks:

tsx
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url)
        const json = await response.json()
        setData(json)
      } catch (err) {
        setError(err instanceof Error ? err : new Error('Unknown error'))
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [url])

  return { data, loading, error }
}

// Usage
interface Post {
  id: number
  title: string
  body: string
}

function Posts() {
  const { data, loading, error } = useFetch<Post[]>('/api/posts')

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <ul>
      {data?.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Context API with TypeScript

Create type-safe contexts:

tsx
interface AuthContextType {
  user: User | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

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

  const login = async (email: string, password: string) => {
    const user = await authenticateUser(email, password)
    setUser(user)
  }

  const logout = () => {
    setUser(null)
  }

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

// Custom hook for consuming context
function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

// Usage
function Profile() {
  const { user, logout } = useAuth()

  return (
    <div>
      <p>Welcome, {user?.name}</p>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

Generic Components

Build reusable components with generics:

tsx
interface ListProps<T> {
  items: T[]
  renderItem: (item: T) => React.ReactNode
  keyExtractor: (item: T) => string | number
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  )
}

// Usage
interface User {
  id: number
  name: string
}

function UserList() {
  const users: User[] = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]

  return (
    <List
      items={users}
      renderItem={(user) => <span>{user.name}</span>}
      keyExtractor={(user) => user.id}
    />
  )
}

Utility Types for React

Leverage TypeScript's utility types:

tsx
// ComponentProps - Extract props from existing components
type ButtonProps = React.ComponentProps<'button'>

function CustomButton(props: ButtonProps) {
  return <button {...props} className="custom-button" />
}

// Pick and Omit
interface FullUser {
  id: string
  name: string
  email: string
  password: string
}

type PublicUser = Omit<FullUser, 'password'>
type UserCredentials = Pick<FullUser, 'email' | 'password'>

// Partial - Make all properties optional
function updateUser(id: string, updates: Partial<User>) {
  // ...
}

// Record - Type for key-value pairs
type FormErrors = Record<string, string>

const errors: FormErrors = {
  email: 'Invalid email',
  password: 'Too short'
}

Best Practices

1. Prefer Interfaces for Props

tsx
// Good
interface UserProps {
  name: string
  age: number
}

// Also fine for unions
type Status = 'idle' | 'loading' | 'success' | 'error'

2. Use Discriminated Unions for State

tsx
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function DataComponent() {
  const [state, setState] = useState<FetchState<User>>({ status: 'idle' })

  if (state.status === 'loading') {
    return <div>Loading...</div>
  }

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

  if (state.status === 'success') {
    return <div>{state.data.name}</div>
  }

  return null
}

3. Type Form Data

tsx
interface LoginFormData {
  email: string
  password: string
}

function LoginForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    const data: LoginFormData = {
      email: formData.get('email') as string,
      password: formData.get('password') as string
    }

    // data is now typed
    submitLogin(data)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button type="submit">Login</button>
    </form>
  )
}

4. Use as const for Constants

tsx
const STATUSES = ['pending', 'approved', 'rejected'] as const
type Status = typeof STATUSES[number] // 'pending' | 'approved' | 'rejected'

const COLORS = {
  primary: '#007bff',
  secondary: '#6c757d',
  success: '#28a745'
} as const

Common Patterns

Higher-Order Components

tsx
function withLoading<P extends object>(
  Component: React.ComponentType<P>
) {
  return function WithLoadingComponent(
    props: P & { loading: boolean }
  ) {
    const { loading, ...rest } = props

    if (loading) return <div>Loading...</div>

    return <Component {...(rest as P)} />
  }
}

// Usage
const UserProfileWithLoading = withLoading(UserProfile)

Render Props

tsx
interface MouseTrackerProps {
  children: (position: { x: number; y: number }) => React.ReactNode
}

function MouseTracker({ children }: MouseTrackerProps) {
  const [position, setPosition] = useState({ x: 0, y: 0 })

  const handleMouseMove = (e: React.MouseEvent) => {
    setPosition({ x: e.clientX, y: e.clientY })
  }

  return <div onMouseMove={handleMouseMove}>{children(position)}</div>
}

// Usage
function App() {
  return (
    <MouseTracker>
      {({ x, y }) => (
        <div>
          Mouse position: {x}, {y}
        </div>
      )}
    </MouseTracker>
  )
}

Resources