React with TypeScript: Best Practices for Props, Hooks, and Component APIs
reacttypescriptcomponentshooksfrontend

React with TypeScript: Best Practices for Props, Hooks, and Component APIs

TTypeScript Website Editorial
2026-06-10
11 min read

A practical guide to typing React props, hooks, and component APIs with TypeScript in a way that stays clear and maintainable.

React and TypeScript work well together, but many teams still struggle with the same practical questions: how strict should prop types be, when should hooks be generic, how do you design component APIs that stay readable as a codebase grows, and which React types help versus getting in the way. This guide is a reusable best-practices hub for typing modern React code. It focuses on props, hooks, and component APIs, with patterns you can keep using as your app, tooling, and framework conventions evolve.

Overview

If you are building React with TypeScript, the goal is not to type everything in the most clever way possible. The goal is to make components easier to use correctly, harder to misuse, and simpler to refactor later. Good React TypeScript patterns improve three things at once: editor feedback, API clarity, and maintenance cost.

A useful rule of thumb is to optimize for the component consumer first. In other words, ask what a teammate should see when they import a component or call a hook. If the type signature explains valid inputs, common states, and expected outputs without requiring deep TypeScript knowledge, the design is probably in a good place.

In practice, the best React TypeScript code tends to follow a few stable principles:

  • Keep prop types close to the component. Small, local types are easier to change than giant shared interfaces.
  • Prefer inference where React and TypeScript already do a good job. Avoid adding type annotations that repeat what the compiler already knows.
  • Model real API constraints. Use unions, discriminated unions, and utility types when they express real usage rules.
  • Avoid broad escape hatches. Types like any, overly wide object, or loose index signatures usually hide mistakes.
  • Design for composition. React APIs age better when they are easy to combine with native element props, custom hooks, and existing components.

These principles matter whether you are learning React with TypeScript, migrating a JavaScript codebase, or tightening an existing design system. They also line up with broader TypeScript best practices around narrowing, generics, and compiler configuration. If you want a refresher on those foundations, see the TypeScript Cheat Sheet, the Generics in TypeScript guide, and tsconfig.json best practices.

Template structure

The easiest way to keep React TypeScript code consistent is to use a repeatable structure for components and hooks. The template below is less about one exact syntax and more about the order of decisions.

1. Start with the public API

Before writing implementation details, define what the component accepts and what behavior the caller can rely on.

type ButtonProps = {
  variant?: 'primary' | 'secondary';
  loading?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
};

export function Button({
  variant = 'primary',
  loading = false,
  children,
  onClick,
}: ButtonProps) {
  return (
    <button data-variant={variant} disabled={loading} onClick={onClick}>
      {loading ? 'Loading...' : children}
    </button>
  );
}

This is a good default because the prop type is explicit, defaults are visible in the function signature, and the caller sees a small, readable API.

2. Merge custom props with native element props carefully

Reusable UI components often wrap native elements. In those cases, extending the right intrinsic React type keeps your component familiar.

type InputProps = {
  label: string;
  error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;

export function Input({ label, error, id, ...props }: InputProps) {
  const inputId = id ?? React.useId();

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input id={inputId} {...props} />
      {error ? <p role="alert">{error}</p> : null}
    </div>
  );
}

This pattern is often better than manually redefining every native prop. It also keeps HTML behavior available, such as placeholder, disabled, and onChange.

That said, avoid blindly extending native props when your component does not behave like the underlying element. If you only support a subset, type that subset directly.

3. Use unions when props are mutually exclusive

One of the strongest React TypeScript patterns is using discriminated unions to prevent invalid prop combinations.

type LinkButtonProps = {
  href: string;
  onClick?: never;
  children: React.ReactNode;
};

type ActionButtonProps = {
  href?: never;
  onClick: () => void;
  children: React.ReactNode;
};

type SmartButtonProps = LinkButtonProps | ActionButtonProps;

export function SmartButton(props: SmartButtonProps) {
  if ('href' in props) {
    return <a href={props.href}>{props.children}</a>;
  }

  return <button onClick={props.onClick}>{props.children}</button>;
}

This is preferable to making both href and onClick optional and hoping consumers choose the right combination. For more on narrowing patterns like in checks, see the TypeScript Narrowing Guide.

4. Let hooks infer as much as possible

Many React hooks do not need heavy type annotations. In fact, too many annotations can make code harder to read.

const [count, setCount] = React.useState(0);
const [name, setName] = React.useState('');

Inference works well here. Add explicit types when state starts empty, nullable, or union-based.

const [user, setUser] = React.useState<User | null>(null);
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');

Prefer meaningful unions over vague strings spread across the codebase.

5. Type event handlers from usage, not memory

React event types are easy to get wrong by hand. A practical approach is to type them based on the element involved.

function SearchBox() {
  const [query, setQuery] = React.useState('');

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setQuery(event.target.value);
  }

  return <input value={query} onChange={handleChange} />;
}

If you are unsure which event type to use, write the handler inline first and let your editor reveal the inferred type, then extract it.

6. Use generics in hooks and reusable abstractions, not everywhere

Generics are most useful when a hook or component truly needs to work across multiple data shapes.

type AsyncState<T> = {
  data: T | null;
  error: Error | null;
  loading: boolean;
};

function useAsyncData<T>(fetcher: () => Promise<T>) {
  const [state, setState] = React.useState<AsyncState<T>>({
    data: null,
    error: null,
    loading: true,
  });

  React.useEffect(() => {
    let active = true;

    fetcher()
      .then((data) => {
        if (active) setState({ data, error: null, loading: false });
      })
      .catch((error: Error) => {
        if (active) setState({ data: null, error, loading: false });
      });

    return () => {
      active = false;
    };
  }, [fetcher]);

  return state;
}

This generic gives real value because it preserves the fetch result shape for each caller. By contrast, adding generics to a component that only has one real data shape usually adds complexity without a payoff.

7. Be cautious with React.FC

For many teams, directly typing props on function parameters is the clearest option.

type CardProps = {
  title: string;
  children: React.ReactNode;
};

export function Card({ title, children }: CardProps) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

This keeps the return type inferred and avoids some of the extra assumptions that come with older component typing habits. The main point is consistency: choose a style that makes prop expectations obvious.

How to customize

The patterns above are strong defaults, but different React codebases need different tradeoffs. The best way to customize them is to decide what kind of API surface you are designing.

For app components

App-level components can usually be typed more directly. They do not need to be reusable in every context, so simplicity matters more than maximum flexibility.

  • Use local prop types instead of exporting everything.
  • Prefer explicit unions for view states like loading, empty, and error.
  • Keep hook return types practical rather than abstract.

Example:

type ProfilePanelProps = {
  userId: string;
};

export function ProfilePanel({ userId }: ProfilePanelProps) {
  const { data, loading, error } = useUserProfile(userId);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Could not load profile.</p>;
  if (!data) return <p>No profile found.</p>;

  return <div>{data.name}</div>;
}

For shared UI libraries and design systems

Reusable libraries need more attention around composition, native props, refs, and slot-like patterns. Here, stricter API design helps prevent misuse across many consumers.

  • Merge custom props with native element props thoughtfully.
  • Use Omit when your component replaces native behavior.
  • Model variant combinations intentionally.
  • Type forwarded refs when the underlying DOM node matters.
type TextareaProps = {
  resize?: 'none' | 'vertical' | 'both';
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'rows'>;

This is a useful pattern when you want to preserve most native props but reserve control over specific ones.

For data-heavy hooks

Custom hooks often become the real type backbone of a React app. A well-typed hook should make impossible states harder to represent.

Instead of returning many loosely related booleans, consider a discriminated union:

type RemoteData<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T };

That gives consumers a clearer branch structure and makes exhaustive UI handling easier.

For form components

Forms are where React TypeScript code often gets noisy. Keep the model narrow. Type values and handlers around actual fields, and do not over-generalize too early. If runtime validation matters, pair TypeScript types with a validation library rather than relying on static types alone. Static types help during development, but incoming user input still needs validation.

If your team frequently works with API payloads or form schemas, it may also be worth standardizing a runtime validation layer and deriving types from it where practical.

For migration from JavaScript to TypeScript

When moving an existing React app into TypeScript, start with the highest-value boundaries:

  1. Shared components used across many screens.
  2. Custom hooks that fetch or transform data.
  3. Common event and form handlers.
  4. Design system primitives such as button, input, modal, and table.

Do not try to model every edge case on day one. A better migration path is to add useful prop types first, then tighten unions and generics as implementation settles. If compiler errors slow the process, the TypeScript Error Guide is a practical companion.

Examples

The following examples show what these practices look like in common React scenarios.

Example 1: A component with variant-safe props

type BannerProps =
  | {
      kind: 'info';
      message: string;
      dismissible?: boolean;
    }
  | {
      kind: 'error';
      message: string;
      retry: () => void;
    };

export function Banner(props: BannerProps) {
  if (props.kind === 'error') {
    return (
      <div role="alert">
        <p>{props.message}</p>
        <button onClick={props.retry}>Retry</button>
      </div>
    );
  }

  return (
    <div>
      <p>{props.message}</p>
      {props.dismissible ? <button>Dismiss</button> : null}
    </div>
  );
}

This API prevents an info banner from accidentally receiving a retry action and makes the component behavior obvious.

Example 2: A typed hook for filtered lists

function useFilteredItems<T>(
  items: T[],
  predicate: (item: T, query: string) => boolean
) {
  const [query, setQuery] = React.useState('');

  const filtered = React.useMemo(() => {
    return items.filter((item) => predicate(item, query));
  }, [items, predicate, query]);

  return { query, setQuery, filtered };
}

The hook is generic in a way that remains readable. It preserves item types for callers while keeping the public API small.

Example 3: A wrapper component that preserves button behavior

type IconButtonProps = {
  icon: React.ReactNode;
  label: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

export function IconButton({ icon, label, ...props }: IconButtonProps) {
  return (
    <button aria-label={label} {...props}>
      {icon}
    </button>
  );
}

This is a common design system pattern. Consumers still get native button props, while the custom API stays focused on what the component adds.

Example 4: A state model that avoids boolean drift

type SaveState =
  | { status: 'idle' }
  | { status: 'saving' }
  | { status: 'saved'; savedAt: Date }
  | { status: 'error'; message: string };

function SaveIndicator({ state }: { state: SaveState }) {
  switch (state.status) {
    case 'idle':
      return <span>No changes</span>;
    case 'saving':
      return <span>Saving...</span>;
    case 'saved':
      return <span>Saved at {state.savedAt.toLocaleTimeString()}</span>;
    case 'error':
      return <span>Error: {state.message}</span>;
  }
}

This pattern is often easier to maintain than juggling flags like isSaving, hasError, and isSaved that may conflict with each other.

Example 5: A practical checklist for component APIs

Before shipping a new typed component, ask:

  • Can the prop names be understood without opening the implementation?
  • Do optional props have sensible defaults?
  • Are invalid prop combinations prevented by the type system?
  • Does the component preserve native element behavior when appropriate?
  • Did we avoid adding generics unless they clearly improve reuse?
  • Would a teammate know how to use this component from autocomplete alone?

If the answer is yes to most of these, the API is probably in good shape.

When to update

The best React TypeScript guidance is not static. Even stable patterns should be revisited when your framework, team conventions, or component architecture changes. Treat this as a living checklist rather than a one-time style decision.

Review your React TypeScript patterns when any of the following happens:

  • Your React framework conventions change. For example, a Next.js upgrade or a shift toward server-first or client-only boundaries can affect component API design.
  • Your shared components become a design system. What worked for app-local components may be too loose for cross-team reuse.
  • Your codebase adopts stricter compiler settings. Changes to strict mode or related tsconfig options often surface weak prop and hook patterns.
  • Your team sees repeated type errors. If the same mistakes keep appearing, your public APIs may need clearer types.
  • Your hooks start returning complex state. This is often the point where unions, helper types, or runtime validation become worth the extra structure.
  • Your publishing or documentation workflow changes. If components are documented in Storybook, MDX, or internal docs, your typing conventions should support those workflows cleanly.

A practical maintenance routine is to audit a small set of components every quarter or during major framework updates. Start with the components that are imported most often. Look for three warning signs:

  1. Props typed too broadly, such as wide string fields that should be unions.
  2. Hooks returning objects with ambiguous nullable fields instead of explicit states.
  3. Wrapper components that accidentally broke native element behavior or accessibility.

Then apply the smallest useful improvement. TypeScript in React scales best when patterns are refined gradually instead of redesigned all at once.

For teams that want a concrete action plan, use this update sequence:

  1. Review tsconfig and linting rules to ensure the type system is actually enforcing your expectations.
  2. Standardize one prop typing pattern for ordinary function components.
  3. Standardize one wrapper pattern for native HTML elements.
  4. Adopt discriminated unions for components or hooks with mutually exclusive states.
  5. Document when generics are encouraged and when they should be avoided.
  6. Capture examples in your internal docs so new components follow the same model.

React with TypeScript is most effective when the type layer reflects real UI constraints rather than abstract ideals. If you keep props explicit, hook outputs predictable, and component APIs consumer-friendly, your code will stay easier to read and safer to evolve. That is the standard worth revisiting as your React stack changes.

For related reading, the most useful follow-ups are the Generics in TypeScript guide, the TypeScript Narrowing Guide, the TypeScript Error Guide, and tsconfig.json Best Practices.

Related Topics

#react#typescript#components#hooks#frontend
T

TypeScript Website Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

2026-06-12T12:05:02.490Z