TypeScript React: Patterns & Pitfalls

Faiaz KhanFaiaz Khan
6 min read

Your React code compiles. But now TypeScript is yelling at you like:

“You can’t assign a string to a number!”

“This might be null!”

“This property doesn’t exist on type ‘never’!”

You try to fight back… by slapping as any everywhere.

Let’s fix that. properly.

This post explores advanced TypeScript concepts in React, with a twist:
We’ll look at what each tool solves, the common mistakes devs make, and how to actually fix them.

Here’s what we’re covering:

  • Type safety — Props, state, refs, events

  • Generic in components

  • Discriminated unions

  • Component composition

  • Type inference + type guards

  • Typing async logic and useEffect


Type Safety in React — Props, State, Refs, Events

The Problem:

You want to pass props, use refs, and manage state — but your types are either too loose or too strict.

The Solution:

Strongly type everything React-related — with care.

Props:

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

const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

useState:

const [count, setCount] = useState<number>(0); // ✅

Refs:

const inputRef = useRef<HTMLInputElement | null>(null);

Events:

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

Common Mistakes:

  • Typing useState(null) without providing a type → stays null forever

  • Forgetting event type → TS can’t help autocomplete

  • useRef(null) without generic → stuck in any | null land


Fix Tips:

  • Always set an explicit type when initial state is null

  • Use React.ChangeEvent<T> or React.MouseEvent<T> for handles

  • For refs, use useRef<Type | null>() with proper type


Generics in Components — Make ‘Em Flexible

The Problem:

You want a component (or hook) to accept any data type, but keep strong typing.

The Solution:

Use generics — like <T> — to create reusable, typed components.

Example:

type ListProps<T> = {
  items: T[];
  render: (item: T) => React.ReactNode;
};

function List<T>({ items, render }: ListProps<T>) {
  return <ul>{items.map(render)}</ul>;
}

Usage:

<List items={['React', 'TypeScript']} render={(item) => <li>{item}</li>} />

Common Mistakes:

  • Using generics without constraints → unsafe access (T could be anything)

  • Overengineering simple types with too many generics

  • Forgetting to pass generic when calling the component/hook


Fix Tips:

  • Use constraints like T extends SomeType to control shape

  • Keep generics as simple as possible

  • Let TS infer types when possible — but not at the cost of safety


Discriminated Unions — Power Your Conditional Logic

The Problem:

You have a component that takes multiple variants (e.g., type: “error” | “success“) — and you’re relying on optional props and ifs everywhere.

The Solution:

Use discriminated unions — TypeScript’s way of narrowing types automatically.

Example:

type Success = { status: 'success'; message: string };
type Error = { status: 'error'; code: number };
type ResultProps = Success | Error;

const Result = (props: ResultProps) => {
  if (props.status === 'success') {
    return <p>{props.message}</p>;
  } else {
    return <p>Error Code: {props.code}</p>;
  }
};

Common Mistakes:

  • Forgetting to add the discriminated key (status, type, kind)

  • Using booleans or string literals inconsistently

  • Getting “properly X does not exist on type Y” errors


Fix Tips:

  • Always include a ”type” or ”status” filed to distinguish types

  • Avoid | undefined unions for control flow — use full shape instead

  • Use switch for clear narrowing in complex scenarios


Component Composition + Types — Be Intentional

The Problem:

You want flexible, composable components — render props, compound components, or children-first APIs — but typing them is a mess.

The Solution:

Use ReactNode, ReactElement, and component-type inference properly.

Children:

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

const Card = ({ children }: CardProps) => <div>{children}</div>;

Render props:

type RenderProp<T> = {
  render: (item: T) => React.ReactNode;
};

Common Mistakes:

  • Typing children as any or string

  • Using React.FC carelessly → implicit children, messy type inference

  • Forgetting to enforce structure in compound components


Fix Tips:

  • Prefer explicit React.ReactNode or ReactElement

  • Use utility types like ComponentType<props> for render props

  • Avoid overusing FC — be explicit when necessary


Type Inference + Type Guards — Let TS Work For You

The Problem:

You’re either typing everything manually or not at all — and wondering why undefined is not assignable to is ruining your day.

The Solution:

Let TS infer when it’s obvious — and use type guards to handle branching.

Inference:

const user = { name: 'Faiaz', age: 25 }; // inferred automatically

Custom Guard:

type Cat = { type: 'cat'; meow: () => void };
type Dog = { type: 'dog'; bark: () => void };
type Pet = Cat | Dog;

function isCat(pet: Pet): pet is Cat {
  return pet.type === 'cat';
}

Common Mistakes:

  • Incomplete type guards that don’t narrow types properly

  • Overwriting good inference with bad manual annotations

  • Mixing inference and any


Fix Tips:

  • Use pet is Type syntax to inform TS of guard outcome

  • Let TS infer return types when they’re obvious

  • Avoid using as unless you’re absolutely sure


Handling Asnychronous Types — Don’t Race Yourself

The Problem:

You’re using fetch and axios, useEffect, async/await, but your types don’t reflect reality. Sometimes data is undefined, sometimes it’s a promise.

The Solution:

Use proper async typing — and separate data, loading, and error clearly.

Example:

type User = { name: string };

const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
  async function fetchUser() {
    try {
      const res = await fetch('/api/user');
      const user: User = await res.json();
      setData(user);
    } finally {
      setLoading(false);
    }
  }
  fetchUser();
}, []);

Common Mistakes:

  • Typing const data: Promise<User> → wrong!

  • Not accounting for null or undefined states

  • Forgetting to handle error/loading logic in JSX


Fix Tips:

  • Never type data as a promise<T> in useState

  • Use union types (T | null) for pending states

  • Break async logic into separate functions when possible


Final Words

TypeScript in React isn’t about perfection — it’s about building confidence in your code. The more you break things (and fix them the right way), the faster you grow.

Write types like your teammates are reading them.
Write bugs like nobody’s watching.


💬 Got a TypeScript horror story? Drop in the comments — let’s learn from it.
📚 More dev rants and React deep dives on usefaiaz.hashnode.dev
💻 Code examples & bonus snippets: github.com/Faiaz98


Happy typing! 💥💥

0
Subscribe to my newsletter

Read articles from Faiaz Khan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Faiaz Khan
Faiaz Khan

Hey! I'm Faiaz — a frontend developer who loves writing clean, efficient, and readable code (and sometimes slightly chaotic code, but only when debugging). This blog is my little corner of the internet where I share what I learn about React, JavaScript, modern web tools, and building better user experiences. When I'm not coding, I'm probably refactoring my to-do list or explaining closures to my cat. Thanks for stopping by — hope you find something useful or mildly entertaining here.