Advanced TypeScript Patterns for React Developers
TypeScriptReactJavaScriptWeb Development

Advanced TypeScript Patterns for React Developers

Master advanced TypeScript patterns and techniques to build more robust and maintainable React applications with better type safety.

ThinMint Team
7 min read

Advanced TypeScript Patterns for React Developers

TypeScript has become essential for modern React development, providing type safety and better developer experience. Let's explore advanced patterns that will take your React TypeScript skills to the next level.

1. Generic Component Props

Create flexible, reusable components with generic props:

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 with full type safety
<List
  items={users}
  renderItem={(user) => <UserCard user={user} />}
  keyExtractor={(user) => user.id}
/>

2. Discriminated Unions for State Management

Handle complex state with discriminated unions:

type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }
 
function useAsyncData<T>(fetcher: () => Promise<T>) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })
 
  const fetchData = useCallback(async () => {
    setState({ status: 'loading' })
    try {
      const data = await fetcher()
      setState({ status: 'success', data })
    } catch (error) {
      setState({ status: 'error', error: error.message })
    }
  }, [fetcher])
 
  return { state, fetchData }
}
 
// Usage with type-safe state handling
function UserProfile({ userId }: { userId: string }) {
  const { state, fetchData } = useAsyncData(() => fetchUser(userId))
 
  switch (state.status) {
    case 'idle':
      return <button onClick={fetchData}>Load User</button>
    case 'loading':
      return <Spinner />
    case 'success':
      return <UserCard user={state.data} /> // data is properly typed
    case 'error':
      return <ErrorMessage error={state.error} />
  }
}

3. Advanced Hook Typing

Create strongly-typed custom hooks:

interface UseLocalStorageReturn<T> {
  value: T
  setValue: (value: T | ((prev: T) => T)) => void
  remove: () => void
}
 
function useLocalStorage<T>(
  key: string, 
  defaultValue: T
): UseLocalStorageReturn<T> {
  const [value, setValue] = useState<T>(() => {
    try {
      const item = localStorage.getItem(key)
      return item ? JSON.parse(item) : defaultValue
    } catch {
      return defaultValue
    }
  })
 
  const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
    setValue(prev => {
      const valueToStore = newValue instanceof Function ? newValue(prev) : newValue
      localStorage.setItem(key, JSON.stringify(valueToStore))
      return valueToStore
    })
  }, [key])
 
  const remove = useCallback(() => {
    localStorage.removeItem(key)
    setValue(defaultValue)
  }, [key, defaultValue])
 
  return { value, setValue: setStoredValue, remove }
}

4. Component Props with Variants

Use template literal types for component variants:

type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
type ButtonSize = 'sm' | 'md' | 'lg'
 
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  loading?: boolean
  leftIcon?: React.ReactNode
  rightIcon?: React.ReactNode
}
 
const buttonVariants: Record<ButtonVariant, string> = {
  primary: 'bg-mint-primary text-charcoal hover:bg-mint-dark',
  secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
  outline: 'border-2 border-mint-primary text-mint-primary hover:bg-mint-primary hover:text-charcoal',
  ghost: 'text-mint-primary hover:bg-mint-primary hover:bg-opacity-10'
}
 
const buttonSizes: Record<ButtonSize, string> = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg'
}
 
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', loading, leftIcon, rightIcon, children, className, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(
          'font-semibold rounded-lg transition-all duration-300 focus:outline-none focus:ring-2',
          buttonVariants[variant],
          buttonSizes[size],
          loading && 'opacity-50 cursor-not-allowed',
          className
        )}
        disabled={loading || props.disabled}
        {...props}
      >
        {loading ? (
          <Spinner size="sm" />
        ) : (
          <>
            {leftIcon && <span className="mr-2">{leftIcon}</span>}
            {children}
            {rightIcon && <span className="ml-2">{rightIcon}</span>}
          </>
        )}
      </button>
    )
  }
)

5. Context with Strict Typing

Create type-safe context with proper error handling:

interface AuthUser {
  id: string
  email: string
  name: string
  avatar?: string
}
 
interface AuthContextValue {
  user: AuthUser | null
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  loading: boolean
}
 
const AuthContext = React.createContext<AuthContextValue | null>(null)
 
export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<AuthUser | null>(null)
  const [loading, setLoading] = useState(true)
 
  const login = useCallback(async (email: string, password: string) => {
    setLoading(true)
    try {
      const user = await authService.login(email, password)
      setUser(user)
    } finally {
      setLoading(false)
    }
  }, [])
 
  const logout = useCallback(async () => {
    await authService.logout()
    setUser(null)
  }, [])
 
  const value: AuthContextValue = {
    user,
    login,
    logout,
    loading
  }
 
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

6. Form Handling with Strict Types

Create type-safe form handling:

interface LoginForm {
  email: string
  password: string
}
 
type FormErrors<T> = Partial<Record<keyof T, string>>
 
function useForm<T extends Record<string, any>>(
  initialValues: T,
  validate: (values: T) => FormErrors<T>
) {
  const [values, setValues] = useState<T>(initialValues)
  const [errors, setErrors] = useState<FormErrors<T>>({})
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
 
  const handleChange = useCallback((name: keyof T, value: T[keyof T]) => {
    setValues(prev => ({ ...prev, [name]: value }))
    if (touched[name]) {
      const newErrors = validate({ ...values, [name]: value })
      setErrors(prev => ({ ...prev, [name]: newErrors[name] }))
    }
  }, [values, touched, validate])
 
  const handleBlur = useCallback((name: keyof T) => {
    setTouched(prev => ({ ...prev, [name]: true }))
    const newErrors = validate(values)
    setErrors(prev => ({ ...prev, [name]: newErrors[name] }))
  }, [values, validate])
 
  const handleSubmit = useCallback((onSubmit: (values: T) => void) => {
    return (e: React.FormEvent) => {
      e.preventDefault()
      const newErrors = validate(values)
      setErrors(newErrors)
      setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {}))
      
      if (Object.keys(newErrors).length === 0) {
        onSubmit(values)
      }
    }
  }, [values, validate])
 
  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit
  }
}
 
// Usage
function LoginForm() {
  const { values, errors, touched, handleChange, handleBlur, handleSubmit } = useForm<LoginForm>(
    { email: '', password: '' },
    (values) => {
      const errors: FormErrors<LoginForm> = {}
      if (!values.email) errors.email = 'Email is required'
      if (!values.password) errors.password = 'Password is required'
      return errors
    }
  )
 
  return (
    <form onSubmit={handleSubmit(handleLogin)}>
      <input
        type="email"
        value={values.email}
        onChange={(e) => handleChange('email', e.target.value)}
        onBlur={() => handleBlur('email')}
      />
      {touched.email && errors.email && <span>{errors.email}</span>}
      
      <input
        type="password"
        value={values.password}
        onChange={(e) => handleChange('password', e.target.value)}
        onBlur={() => handleBlur('password')}
      />
      {touched.password && errors.password && <span>{errors.password}</span>}
      
      <button type="submit">Login</button>
    </form>
  )
}

7. API Response Typing

Structure API responses with proper typing:

interface ApiResponse<T> {
  data: T
  message: string
  success: boolean
}
 
interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}
 
// Generic API function
async function apiRequest<T>(
  url: string, 
  options?: RequestInit
): Promise<ApiResponse<T>> {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  })
 
  if (!response.ok) {
    throw new Error(`API request failed: ${response.statusText}`)
  }
 
  return response.json()
}
 
// Usage with proper typing
interface User {
  id: string
  name: string
  email: string
}
 
async function fetchUsers(page = 1): Promise<PaginatedResponse<User>> {
  return apiRequest<User[]>(`/api/users?page=${page}`)
}
 
async function fetchUser(id: string): Promise<ApiResponse<User>> {
  return apiRequest<User>(`/api/users/${id}`)
}

8. Component Composition with Slots

Create flexible component APIs using slots pattern:

interface CardProps {
  children: React.ReactNode
  className?: string
}
 
interface CardHeaderProps {
  children: React.ReactNode
  actions?: React.ReactNode
}
 
interface CardBodyProps {
  children: React.ReactNode
}
 
interface CardFooterProps {
  children: React.ReactNode
}
 
function Card({ children, className }: CardProps) {
  return (
    <div className={cn('bg-white rounded-lg shadow-lg overflow-hidden', className)}>
      {children}
    </div>
  )
}
 
function CardHeader({ children, actions }: CardHeaderProps) {
  return (
    <div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
      <div>{children}</div>
      {actions && <div>{actions}</div>}
    </div>
  )
}
 
function CardBody({ children }: CardBodyProps) {
  return <div className="px-6 py-4">{children}</div>
}
 
function CardFooter({ children }: CardFooterProps) {
  return <div className="px-6 py-4 border-t border-gray-200">{children}</div>
}
 
// Compound component pattern
Card.Header = CardHeader
Card.Body = CardBody
Card.Footer = CardFooter
 
// Usage
<Card>
  <Card.Header actions={<Button>Edit</Button>}>
    <h3>User Profile</h3>
  </Card.Header>
  <Card.Body>
    <p>User information goes here...</p>
  </Card.Body>
  <Card.Footer>
    <Button variant="outline">Cancel</Button>
    <Button>Save</Button>
  </Card.Footer>
</Card>

Conclusion

These advanced TypeScript patterns will help you build more robust, maintainable React applications. The key is to leverage TypeScript's type system to catch errors at compile time and provide better developer experience.

Remember, the goal is not to use every advanced feature, but to choose the right patterns for your specific use cases and team needs.

Want to see these patterns in action? Check out our React TypeScript starter that implements all these patterns and more.