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:
bashnpm 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:
tsxinterface 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:
tsxinterface 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:
tsxinterface 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:
tsxfunction 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:
tsxinterface 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:
tsxtype 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:
tsxfunction 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:
tsxinterface 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:
tsxinterface 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
tsxtype 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
tsxinterface 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
tsxconst 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
tsxfunction 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
tsxinterface 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> ) }