React: TypeScript + React Best Practices
Last updated: 2026-08-26
While the Tom team was developing the payment module, a serious bug occurred in production: because a certain component expected
userIdbut receivedstringfrom the upstream, the API requisição failed. If the project used TypeScript, this type mismatch would have been detected during the compilation phase. Tom decided to adopt TypeScript for the entire project to catch such issues early on.
1. What You'll Learn
- Props Type Definitions (Strategies for Choosing Between
interfaceandtype) - How the Generic Component
<T>Works - React Event Type System (ChangeEvent / MouseEvent / KeyboardEvent)
- Complete type annotations for custom hooks
- Tips for Extending the Types of HTML Element Attributes
2. Conceptual Diagrams
The following diagram illustrates the type checking performed by TypeScript in the data flow of a React component:
flowchart LR
subgraph Compilation Phase
A[Parent Component] -->|"Props Type Checking"| B[Child component]
B -->|"State Type Inference"| C[useState]
C -->|"Event Type Validation"| D["onChange / onClick"]
end
subgraph Runtime
E["Actual DOM Event"] --> F["Type Matching"]
F -->|"Through"| G[Execute as usual]
F -->|"Type mismatch"| H[Compilation error]
end
I["interface / type Definition"] --> A
J["Generic Parameters <T>"] --> B
K["React.ChangeEvent"] --> D
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#e8f5e9,stroke:#2e7d32
style D fill:#fff3e0,stroke:#e65100
3. A Real-Life Scenario
| TypeScript Scenario | Problem Solved | Key Syntax |
|---|---|---|
| Prop Type Definition | Incorrect Type Passed on Call | interface Props { name: string } |
| Event Type | onChange Parameter Type Inference | e: React.ChangeEvent<HTMLInputElement> |
| Generic Components | Parameterizing Data Types for Lists/Tables | <T> Generic Parameters |
| Hook Types | Type Inference for useState/useRef | useState<string[]> |
| API Response Type | Constraints on the Structure of the Interface Return Value | interface ApiResponse { data: User[] } |
Tom's payment project includes the following data flow:
Order List Page → PaymentCard Components → AmountInput → Submit API
Without TypeScript, AmountInput expected onSubmit(value: number), but the list page passed onSubmit(value: string), resulting in the API receiving "99.99" instead of 99.99, causing a serialization error on the backend.
TypeScript handles this very simply—by explicitly declaring parameter types in component interfaces, and any type mismatch will result in an error during the npm run build phase.
(1) Component Props Type Definitions
There are two main ways to define props in TypeScript: interface and type. The rules for choosing between them are as follows:
| Method | Applicable Scenarios | Features |
|---|---|---|
interface |
Defining Object Types: Props / State | Declaration merging is supported, offering good performance |
type |
Union types, tool types, tuples | More flexible, supports cross-types and conditional types |
Rule of thumb: Use
interfaceto define props/state, and usetypeto define union types/utility types.
Basic Props Definitions
interface ButtonProps {
/** Button Text */
label: string
/** Variant Styles */
variant?: 'primary' | 'danger' | 'default'
/** Button Size */
size?: 'small' | 'medium' | 'large'
/** Is it disabled? */
disabled?: boolean
/** Click to Callback */
onClick: () => void
/** Child elements(Icons on buttons, etc.) */
children?: React.ReactNode
}
function Button({
label,
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: size === 'small' ? '4px 12px' : size === 'large' ? '12px 28px' : '8px 20px',
background: variant === 'danger' ? '#ff4d4f' : variant === 'primary' ? '#1890ff' : '#f0f0f0',
color: variant === 'default' ? '#333' : '#fff',
border: 'none',
borderRadius: 6,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: 'all 0.2s',
}}
>
{children}
{label}
</button>
)
}
▶ Example 1: Advanced Props Types—Extending Native HTML Attributes
Output:
Fade in/out: opacity 0→1 over 300ms. Toggle button shows/hides element with smooth CSS transition.
In actual development, components often need to pass through native HTML attributes (such as id, className, and aria-*). You can use ComponentPropsWithoutRef to inherit them:
import { ComponentPropsWithoutRef } from 'react'
// Method 1:Extend Native button Properties(Recommendations)
interface PrimaryButtonProps
extends ComponentPropsWithoutRef<'button'> {
/** Loading Status */
loading?: boolean
/** Icon Name */
icon?: string
}
function PrimaryButton({
loading,
icon,
children,
disabled,
...rest // Remaining Native button Properties
}: PrimaryButtonProps) {
return (
<button
{...rest}
disabled={disabled || loading}
style={{
padding: '8px 24px',
background: loading ? '#91d5ff' : '#1890ff',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: loading ? 'wait' : 'pointer',
}}
>
{loading ? 'Loading......' : icon ? `${icon} ${children}` : children}
</button>
)
}
// Usage — Both native properties and custom properties can be passed in
<PrimaryButton
id="submit-btn"
loading={isSubmitting}
icon=">"
onClick={() => submit()}
aria-label="Submit Form"
>
Submit
</PrimaryButton>
Key Point: ComponentPropsWithoutRef<'button'> automatically includes all native button attributes, such as onClick, disabled, id, className, style, and aria-*. Use ...rest to apply these to the button element; there is no need to declare them individually.
(2) Generic Components
Generic components allow a component to handle multiple data types while maintaining type safety. The most common use cases are list, table, and selector components.
▶ Example 2: Generic List Component
Output:
<list /> component
import { ReactNode } from 'react'
// Generic Interfaces — T For list item types
interface ListProps<T> {
/** Data Sources */
items: T[]
/** Render each item */
renderItem: (item: T, index: number) => ReactNode
/** The Only One key Extract Function */
keyExtractor: (item: T) => string | number
/** Placeholder text when the list is empty */
emptyText?: string
}
// Generic Components — <T,> Grammar(TS for JSX Compatible syntax)
function List<T>({
items,
renderItem,
keyExtractor,
emptyText = 'No data available',
}: ListProps<T>) {
if (items.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
{emptyText}
</div>
)
}
return (
<div>
{items.map((item, index) => (
<div key={keyExtractor(item)} style={{ marginBottom: 8 }}>
{renderItem(item, index)}
</div>
))}
</div>
)
}
// --- Examples of Use ---
interface User {
id: number
name: string
role: 'admin' | 'user'
}
const users: User[] = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'user' },
]
// Automatic Type Inference:List<User>
// items Automatically inferred as User[],renderItem 's item Automatically set to User
function UserList() {
return (
<List
items={users}
keyExtractor={user => user.id}
renderItem={(user, index) => (
<div
style={{
padding: '8px 16px',
background: index % 2 === 0 ? '#fafafa' : '#fff',
borderRadius: 4,
}}
>
<span style={{ fontWeight: 600 }}>{user.name}</span>
<span
style={{
marginLeft: 8,
color: user.role === 'admin' ? '#1890ff' : '#999',
fontSize: 12,
}}
>
{user.role}
</span>
</div>
)}
/>
)
}
Output:
TypeScript props: interface ButtonProps { text: string; onClick: () => void; color?: string }. Editor auto-completes, compile-time errors on misuse.
Key Mechanisms of Generics:
TinListProps<T>is a "type parameter" that is automatically inferred by TypeScript when used.- When
items={users}(of typeUser[]) is passed in,iteminrenderItemautomatically becomes of typeUser keyExtractor'sitemalso automatically changes toUser, and callinguser.idprovides complete type hints.
(3) React Event Types
React wraps native DOM events using its own composite event system. For each event type, you must specify the type of HTML element to which it is bound in order to obtain the correct currentTarget type.
| Event Type | Corresponding Element | Common Scenarios |
|---|---|---|
ChangeEvent<HTMLInputElement> |
input / textarea / select | Form input |
ChangeEvent<HTMLSelectElement> |
select | drop-down menu |
MouseEvent<HTMLButtonElement> |
button / div | Click |
FormEvent<HTMLFormElement> |
form | Form Submission |
KeyboardEvent<HTMLInputElement> |
input | keyboard shortcut |
FocusEvent<HTMLInputElement> |
input | focus/out-of-focus |
▶ Example 3: Complete Event Type Search Form
Output:
State: query, issearching. buttons: x, {issearching ? 'searching......' : 'search'}
import { useState } from 'react'
interface SearchFormProps {
/** Search Callback */
onSearch: (query: string) => Promise<void>
/** Placeholder text */
placeholder?: string
}
function SearchForm({ onSearch, placeholder = 'Search...' }: SearchFormProps) {
const [query, setQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
// ChangeEvent<HTMLInputElement> — input Value Changes
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value)
}
// KeyboardEvent<HTMLInputElement> — Keyboard Events
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Escape') {
e.currentTarget.blur() // currentTarget is HTMLInputElement
setQuery('')
}
}
// FormEvent<HTMLFormElement> — Form Submission
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!query.trim()) return
setIsSearching(true)
try {
await onSearch(query.trim())
} finally {
setIsSearching(false)
}
}
// MouseEvent<HTMLButtonElement> — Click the Clear button
function handleClear(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation() // Prevent Event Bubbling
setQuery('')
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8 }}>
<div style={{ position: 'relative', flex: 1 }}>
<input
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
}}
/>
{query && (
<button
type="button"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
border: 'none',
background: 'none',
cursor: 'pointer',
color: '#999',
}}
>
x
</button>
)}
</div>
<button
type="submit"
disabled={isSearching || !query.trim()}
style={{
padding: '8px 20px',
background: isSearching ? '#91d5ff' : '#1890ff',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: isSearching ? 'wait' : 'pointer',
fontSize: 14,
}}
>
{isSearching ? 'Searching......' : 'Search'}
</button>
</form>
)
}
export default SearchForm
Output:
TypeScript props: interface ButtonProps { text: string; onClick: () => void; color?: string }. Editor auto-completes, compile-time errors on misuse.
Key Points on Event Types:
React.ChangeEvent<HTMLInputElement>— The generic parameter is the type of the element bound to the event, which determines the types ofe.targetande.currentTargete.currentTargetis the element to which the event is bound (type-safe), ande.targetis the element that actually triggers the event (which may be a child element)KeyboardEventtoe.keyreturns a string; no additional type is required
▶ Example 4: Type Annotations for Custom Hooks
Output:
Displays: "Promise". State: query (setter: setQuery), isSearching (setter: setIsSearching). Buttons: x, {isSearching ? 'Searching......' : 'Search'}. Input types: text, submit, button. Form with submit handling. Async data fetching/loading states
Custom hooks also require complete type annotations, especially when using generics to allow callers to specify data types:
import { useState, useEffect, useCallback } from 'react'
// Definition Hook Interface for Return Values
interface UseFetchResult<T> {
/** Return Data */
data: T | null
/** Loading... */
loading: boolean
/** Error Message */
error: string | null
/** Manually Resend Request */
refetch: () => void
}
// Generics Hook — Specified by the caller T Type
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const json: T = await response.json()
setData(json)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
} finally {
setLoading(false)
}
}, [url])
useEffect(() => {
fetchData()
}, [fetchData])
return { data, loading, error, refetch: fetchData }
}
// --- Usage —— Type annotations are easy to understand at a glance ---
interface UserProfile {
id: number
name: string
email: string
avatar: string
}
function ProfilePage({ userId }: { userId: number }) {
// data Automatically inferred as UserProfile | null
const { data: user, loading, error, refetch } =
useFetch<UserProfile>(`/api/users/${userId}`)
if (loading) return <div>Loading......</div>
if (error) return <div style={{ color: 'red' }}>Error:{error}</div>
if (!user) return <div>No data</div>
return (
<div>
<img src={user.avatar} alt={user.name} width={64} />
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={refetch}>Refresh</button>
</div>
)
// ✅ user.name / user.email / user.avatar All have type hints
// ❌ user.phone Compilation errors occur(UserProfile Not included phone)
}
Key Principles for Hook Type Annotations:
- Define the return value as
interface, and add JSDoc comments to the fields (the editor will display them automatically). useState<T | null>Let TypeScript know thatdatamay be null, and that it must be checked for null when used- The generic parameter
<T>is passed in by the caller, anduseFetch<UserProfile>causes all T's insideuseFetchto becomeUserProfile
(4) Advanced Type Techniques: Conditional Props and Omit
Conditional Props Pattern
When the existence of one prop depends on another, you can use the "recognizable union" pattern:
// Regular Button vs Link Button — variant as 'link' Must Be Passed On href
type ButtonVariant =
| { variant: 'primary' | 'danger' | 'default' }
| { variant: 'link'; href: string; target?: '_blank' | '_self' }
interface SmartButtonProps {
label: string
} & ButtonVariant
function SmartButton(props: SmartButtonProps) {
if (props.variant === 'link') {
// Here props.href Type-safe existence
return <a href={props.href} target={props.target}>{props.label}</a>
}
return <button>{props.label}</button>
}
Use Omit to omit unnecessary native properties
import { ComponentPropsWithoutRef } from 'react'
// Custom Input Components,Pass-through is not allowed type(Force to text)
type CustomInputProps = Omit<
ComponentPropsWithoutRef<'input'>,
'type'
> & {
label: string
}
function CustomInput({ label, ...inputProps }: CustomInputProps) {
return (
<label>
{label}
<input type="text" {...inputProps} />
</label>
)
}
▶ Example 5: Generic Components—Type-Safe Data Tables
Output:
TypeScript-typed React component with interface props
interface Column<T> {
key: keyof T & string
title: string
render?: (value: T[keyof T], row: T) => React.ReactNode
}
function DataTable<T extends Record<string, any>>({ data, columns }: { data: T[]; columns: Column<T>[] }) {
return (
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead>
<tr>
{columns.map(col => (
<th key={col.key} style={{ border: '1px solid #ddd', padding: 8, textAlign: 'left', background: '#f5f5f5' }}>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.key} style={{ border: '1px solid #ddd', padding: 8 }}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
interface User {
id: number
name: string
email: string
active: boolean
}
function UserTable() {
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@test.com', active: true },
{ id: 2, name: 'Bob', email: 'bob@test.com', active: false },
]
const columns: Column<User>[] = [
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
{ key: 'active', title: 'Status', render: (v) => (
<span style={{ color: v ? '#52c41a' : '#999' }}>{v ? 'Active' : 'Inactive'}</span>
)},
]
return <DataTable data={users} columns={columns} />
}
Output:
Generic component: <List<string> items={["a","b"]} renderItem={(item) => ...} />. Type-safe for any data type.
❓ FAQ
interface and type?interface to define props and state (they can be declared together and offer better performance); use type to define union types, cross types, and utility types (e.g., type Status = 'loading' | 'success' | 'error'). The two are interchangeable in most scenarios, but in team projects, it’s recommended to standardize on one as the primary choice.React.FC Why is it no longer recommended?React.FC (or React.FunctionComponent) includes the children property by default, but in actual development, many components do not require children, which results in overly loose typing. Additionally, React.FC does not support generic components. The current community best practice is to directly type-annotate function parameters and no longer use React.FC.e.target and e.currentTarget?e.currentTarget is the element to which the event is bound (type-safe), while e.target is the element that actually triggers the event (which may be a child element). For example, if onChange is bound to an input, e.currentTarget is always that input, but e.target could be an element inside the input. In TypeScript, you can use e.currentTarget to get the exact element type.extends to constrain the generic parameter. For example, <T extends { id: string | number }> ensures that T must include an id field. If the passed-in type does not have an id field, TypeScript will throw an error.React.FC (i.e., React.FunctionComponent). The reasons are: ① It implicitly adds children?: ReactNode, even if your component doesn’t need children; ② The generic syntax is cumbersome (React.FC<Props> vs. simply ({ prop }: Props) => JSX.Element); ③ Type inference is less accurate with default exports than when explicitly specifying parameter types. It is recommended to explicitly specify function parameter types: function Comp({ name }: Props) {}.📖 Summary
- The
propstype is defined usinginterface, andComponentPropsWithoutRefextends native HTML attributes - The generic component
<T>allows container components such as lists and tables to handle multiple data types while maintaining type safety. - React event types are generic:
ChangeEvent<T>,MouseEvent<T>, where T is the element type - Custom hooks use generic return types to support type inference; use
interfaceto define the return type structure - Props (distinguishable unions) and
Omitare advanced type techniques used to precisely control component interfaces
📝 Exercises
- Create a
Tablecomponent using TypeScript: a generic<T extends { id: string | number }>that supports column configuration (columns:{ key: keyof T, title: string }) and implements sorting functionality. - Create a custom
useLocalStorage<T>hook using TypeScript: Ensure type safety during read and write operations, support default values, and automatically handle JSON serialization and deserialization. - Create a
PasswordInputcomponent usingOmitandComponentPropsWithoutRef: It inherits all native input properties, buttypeis set to"password", and an additionalshowToggleprop is added to toggle password visibility.