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 userId but received string from 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



2. Conceptual Diagrams

The following diagram illustrates the type checking performed by TypeScript in the data flow of a React component:

100%
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:

TEXT 📖 Display only
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 interface to define props/state, and use type to define union types/utility types.

Basic Props Definitions

TSX
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:

TEXT 📖 Display only
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:

TSX
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:

TEXT 📖 Display only
<list /> component
TSX
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:

TEXT 📖 Display only
TypeScript props: interface ButtonProps { text: string; onClick: () => void; color?: string }. Editor auto-completes, compile-time errors on misuse.

Key Mechanisms of Generics:

  1. T in ListProps<T> is a "type parameter" that is automatically inferred by TypeScript when used.
  2. When items={users} (of type User[]) is passed in, item in renderItem automatically becomes of type User
  3. keyExtractor's item also automatically changes to User, and calling user.id provides 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:

TEXT 📖 Display only
State: query, issearching. buttons: x, {issearching ? 'searching......' : 'search'}
TSX
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:

TEXT 📖 Display only
TypeScript props: interface ButtonProps { text: string; onClick: () => void; color?: string }. Editor auto-completes, compile-time errors on misuse.

Key Points on Event Types:

  1. React.ChangeEvent<HTMLInputElement> — The generic parameter is the type of the element bound to the event, which determines the types of e.target and e.currentTarget
  2. e.currentTarget is the element to which the event is bound (type-safe), and e.target is the element that actually triggers the event (which may be a child element)
  3. KeyboardEvent to e.key returns a string; no additional type is required

▶ Example 4: Type Annotations for Custom Hooks

Output:

TEXT 📖 Display only
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:

TSX
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:

  1. Define the return value as interface, and add JSDoc comments to the fields (the editor will display them automatically).
  2. useState<T | null> Let TypeScript know that data may be null, and that it must be checked for null when used
  3. The generic parameter <T> is passed in by the caller, and useFetch<UserProfile> causes all T's inside useFetch to become UserProfile

(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:

TSX
// 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

TSX
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:

TEXT 📖 Display only
TypeScript-typed React component with interface props
TSX
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:

TEXT 📖 Display only
Generic component: <List<string> items={["a","b"]} renderItem={(item) => ...} />. Type-safe for any data type.

❓ FAQ

Q How do you decide between interface and type?
A A simple rule: Use 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.
Q React.FC Why is it no longer recommended?
A 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.
Q What is the difference between e.target and e.currentTarget?
A 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.
Q How do I constrain a generic component’s type parameter to require a specific field?
A Use 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.
Q Should we still use React.FC?
A The React team no longer recommends using 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


📝 Exercises

  1. Create a Table component using TypeScript: a generic <T extends { id: string | number }> that supports column configuration (columns: { key: keyof T, title: string }) and implements sorting functionality.
  2. 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.
  3. Create a PasswordInput component using Omit and ComponentPropsWithoutRef: It inherits all native input properties, but type is set to "password", and an additional showToggle prop is added to toggle password visibility.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏