JavaScript powers the web, but it comes with a big tradeoff: it’s dynamically typed. That means type errors like passing a string where a number was expected only show up at runtime. For small scripts, this isn’t a big deal. But in large React applications, these issues can cause frustrating bugs that are hard to trace.

That’s where TypeScript comes in. TypeScript adds static typing to JavaScript, which means you catch errors earlier, improve editor autocomplete, and make your codebase easier to maintain. When combined with React, TypeScript can significantly improve how you work with components, state, and props.

In this article, we’ll explore common patterns for using React with TypeScript, including props, state, events, children, and custom hooks. Along the way, we’ll look at best practices and mistakes to avoid.

Why TypeScript with React?

Before diving into patterns, let’s understand the main benefits of using TypeScript in React projects:

Pattern 1. Typing Props

Props are the foundation of React components, and TypeScript makes them safer.

Example

type ButtonProps = {
  label: string;
  onClick: () => void;
  disabled?: boolean;
};

function Button({ label, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

Here

If you forget label or onClick. TypeScript raises an error.

Pattern 2. Typing State with useState

React’s useState hook is strongly typed, but TypeScript sometimes needs a hint.

const [count, setCount] = React.useState<number>(0);

Here, the state must always be a number.

For a more complex state:

type User = { name: string; age: number };

const [user, setUser] = React.useState<User | null>(null);

This state can either be an User object or null. This prevents you from accidentally calling user.name when no user exists.

Pattern 3. Typing Events

Event handlers are a common source of confusion in React + TypeScript. Luckily, React provides built-in types.

Example: typing an input change event:

function TextInput() {
  const [value, setValue] = React.useState("");

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

  return <input type="text" value={value} onChange={handleChange} />;
}

Other common event types:

Pattern 4. Typing Components with Children

Many components wrap others using children. In TypeScript, you can type this with React.ReactNode.

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

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h3>{title}</h3>
      <div>{children}</div>
    </div>
  );
}

This ensures Card can accept text, JSX, or other components.

Pattern 5. Typing Custom Hooks

Custom hooks are where TypeScript shines because you can define both input and output types.

Example: localStorage hook:

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = React.useState<T>(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });

  React.useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// usage
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");

Notice the <T>, This makes the hook generic so it can work with any type.

Pattern 6. Typing Context

React Context works seamlessly with TypeScript. Example:

type AuthContextType = {
  user: { name: string } | null;
  login: (name: string) => void;
  logout: () => void;
};

const AuthContext = React.createContext<AuthContextType | undefined>(undefined);

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = React.useState<{ name: string } | null>(null);

  const login = (name: string) => setUser({ name });
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

function useAuth() {
  const context = React.useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within AuthProvider");
  return context;
}

This ensures any component using it useAuth knows exactly what methods and data are available.

Pattern 7. Typing Refs

Refs can be typed too. Example:

function InputFocus() {
  const inputRef = React.useRef<HTMLInputElement>(null);

  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus</button>
    </>
  );
}

Here, inputRef is explicitly typed as an HTMLInputElement.

Best Practices

Conclusion

TypeScript transforms the way you write React applications. By typing props, state, events, children, context, and hooks, you make your components safer and more predictable. The key is to use typing wisely, avoid any, don’t overcomplicate types, and let TypeScript infer where possible.

The result is code that’s not only less error-prone but also easier to read and maintain. As your React projects scale, TypeScript becomes less of an optional extra and more of an essential tool.

If you’re building production-grade React apps, embracing TypeScript is one of the smartest moves you can make.