
Advanced TypeScript Patterns for React Developers
Master advanced TypeScript patterns and techniques to build more robust and maintainable React applications with better type safety.
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.