正在加载,请稍候…

TypeScript + React Best Practices: Typing Components, Props, and Hooks

Write better TypeScript React code: typing props with interfaces, using generic components, typing hooks, discriminated unions for state, and avoiding common TS errors.

Setting Up TypeScript in a React Project

# New project with Vite (recommended in 2026)
npm create vite@latest my-app -- --template react-ts

# Add TypeScript to existing React project
npm install -D typescript @types/react @types/react-dom
npx tsc --init

Typing Component Props

Interface vs type alias

// Interface — preferred for props (extensible, clearer error messages)
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
  children?: React.ReactNode;
}

// Type alias — good for unions and computed types
type ButtonVariant = 'primary' | 'secondary' | 'danger';

Function components

// Modern approach — don't use React.FC (it was deprecated in React 18)
function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) {
  return (
    <button
      className={clsx('btn', `btn-${variant}`)}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

// Arrow function style
const Button = ({ label, onClick }: ButtonProps) => (
  <button onClick={onClick}>{label}</button>
);

// Export with type
export type { ButtonProps };
export { Button };

Extending HTML element props

// Extend native HTML props — users can pass any valid button attribute
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  label: string;
  variant?: 'primary' | 'secondary';
  isLoading?: boolean;
}

function Button({ label, variant = 'primary', isLoading, ...rest }: ButtonProps) {
  return (
    <button
      className={clsx('btn', `btn-${variant}`, { loading: isLoading })}
      disabled={isLoading || rest.disabled}
      {...rest}  // spreads onClick, type, form, etc.
    >
      {isLoading ? <Spinner /> : label}
    </button>
  );
}

Children types

interface CardProps {
  children: React.ReactNode;     // most permissive: anything React can render
  header?: React.ReactElement;   // must be a React element (not string/number)
  footer?: string | React.ReactElement; // string or element
}

// For components that only accept certain children
interface ListProps {
  children: React.ReactElement<ListItemProps> | React.ReactElement<ListItemProps>[];
}

Discriminated Unions for Component States

// ❌ Unclear: which combinations are valid?
interface DataViewProps {
  status: 'idle' | 'loading' | 'success' | 'error';
  data?: User[];
  error?: Error;
}

// ✅ Clear: each state is explicit
type DataViewProps =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: Error };

function DataView(props: DataViewProps) {
  if (props.status === 'idle') return <EmptyState />;
  if (props.status === 'loading') return <Spinner />;
  if (props.status === 'error') return <ErrorMessage error={props.error} />;
  // TypeScript knows status is 'success' and data: User[] exists here
  return <UserList users={props.data} />;
}

Typing Hooks

useState

// TypeScript infers the type from initial value
const [count, setCount] = useState(0);           // number
const [name, setName] = useState('');             // string

// Explicit type when initial value is null/undefined
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);

// Complex state
interface FormState {
  name: string;
  email: string;
  errors: Record<string, string>;
}

const [form, setForm] = useState<FormState>({
  name: '',
  email: '',
  errors: {},
});

useRef

// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);

// Mutable value ref (no null)
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const countRef = useRef<number>(0);

// Usage
useEffect(() => {
  inputRef.current?.focus(); // optional chaining because DOM refs start null
}, []);

useReducer

type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; payload: number }
  | { type: 'RESET' };

interface CounterState {
  count: number;
  history: number[];
}

function reducer(state: CounterState, action: Action): CounterState {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1, history: [...state.history, state.count] };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    case 'SET':
      return { ...state, count: action.payload }; // TypeScript knows payload exists
    case 'RESET':
      return { count: 0, history: [] };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });

  return (
    <div>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'SET', payload: 10 })}>Set 10</button>
    </div>
  );
}

Custom Hooks

// Return a tuple — type with 'as const'
function useToggle(initial = false): [boolean, () => void] {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle];
}

// Return an object
function 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 controller = new AbortController();
    setLoading(true);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage — TypeScript infers User[] type
const { data, loading, error } = useFetch<User[]>('/api/users');

Generic Components

// A list component that works for any type
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
  if (items.length === 0) return <div className="empty-state">{emptyMessage}</div>;

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// Usage with TypeScript inference
interface User {
  id: string;
  name: string;
  email: string;
}

function UserList({ users }: { users: User[] }) {
  return (
    <List<User>
      items={users}
      keyExtractor={(user) => user.id}
      renderItem={(user) => <span>{user.name} — {user.email}</span>}
      emptyMessage="No users found"
    />
  );
}

Event Handler Types

// Common event handler types
function Form() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log(e.clientX, e.clientY);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') handleSubmit(e as any);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyDown={handleKeyDown} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

Context with TypeScript

interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// Create context with a sensible default or undefined
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);

// Custom hook with null check
function useTheme(): ThemeContextValue {
  const context = React.useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used inside ThemeProvider');
  }
  return context;
}

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const toggleTheme = useCallback(() => {
    setTheme(t => (t === 'light' ? 'dark' : 'light'));
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

Common TypeScript Errors in React (and fixes)

Error Cause Fix
Type 'string' is not assignable to type 'never' Discriminated union mismatch Add proper type guards
Object is possibly 'null' useRef or conditional value Optional chaining ?. or null check
Property 'X' does not exist on type 'Y' Using wrong interface Check prop types or extend interface
Cannot invoke an object which is possibly 'undefined' Optional function prop Guard with onClick?.()
JSX element type does not have any construct signatures Passing component as prop without correct type Use React.ComponentType<Props>

Quick Reference

// Children
children: React.ReactNode           // anything renderable
children: React.ReactElement        // must be JSX element
children: React.PropsWithChildren<Props> // adds children to Props

// Refs
ref: React.RefObject<HTMLDivElement>  // read-only
ref: React.MutableRefObject<number>  // mutable

// Events
onClick: React.MouseEventHandler<HTMLButtonElement>
onChange: React.ChangeEventHandler<HTMLInputElement>
onSubmit: React.FormEventHandler<HTMLFormElement>

// Component types
type FC = (props: Props) => React.ReactElement | null
ComponentType<Props>  // class or function component
ElementType           // string tag or component

Summary

Strong TypeScript + React practices in 2026:

  1. Use interfaces for props — more extensible and better error messages
  2. Discriminated unions for component states — eliminate impossible states
  3. Type hooks explicitly when TypeScript can't infer (useState with null, useRef)
  4. Generic components for reusable lists, tables, and form fields
  5. Extend HTML attributes so custom components still accept native props
  6. useContext with custom hooks — throw at the hook, not at every callsite
  7. Avoid React.FC — it was deprecated in React 18, just type props directly

→ Practice with real TypeScript tools at JSON Viewer and URL Parser.