DEV Community

AttractivePenguin
AttractivePenguin

Posted on

5 Common TypeScript Mistakes in React (And How to Fix Them)

5 Common TypeScript Mistakes in React (And How to Fix Them)

TypeScript has become the de facto standard for React development, and for good reason: it catches bugs at compile time, improves IDE support, and makes refactoring less terrifying. But here's the thing—adopting TypeScript doesn't automatically mean you're writing type-safe code.

After reviewing dozens of React codebases, I've noticed the same TypeScript mistakes popping up over and over. These aren't obscure edge cases; they're patterns that undermine the very benefits TypeScript promises to deliver.

Let's walk through the five most common mistakes and, more importantly, how to fix them.


Mistake #1: Overusing any (The Type Safety Killer)

The any type is TypeScript's "I give up" button. It turns off type checking entirely, effectively creating a hole in your type system.

The Problem

// ❌ Bad: any defeats the purpose of TypeScript
const fetchUserData = async (id: string): Promise<any> => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};

const UserProfile = ({ userId }: { userId: string }) => {
  const [user, setUser] = useState<any>(null);

  useEffect(() => {
    fetchUserData(userId).then(setUser);
  }, [userId]);

  // TypeScript won't catch typos here
  return <div>{user?.nane}</div>; // Oops, typo!
};
Enter fullscreen mode Exit fullscreen mode

The Fix

Use proper types. If you don't know the shape yet, use unknown and narrow it down.

// ✅ Better: Define proper types
interface User {
  id: string;
  name: string;
  email: string;
  avatar?: string;
}

const fetchUserData = async (id: string): Promise<User> => {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('Failed to fetch user');
  return response.json() as Promise<User>;
};

const UserProfile = ({ userId }: { userId: string }) => {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetchUserData(userId)
      .then(setUser)
      .catch(setError);
  }, [userId]);

  if (error) return <div>Error loading user</div>;
  if (!user) return <div>Loading...</div>;

  return <div>{user.name}</div>; // TypeScript catches typos now
};
Enter fullscreen mode Exit fullscreen mode

Real-world scenario: A team inherited a codebase with any everywhere. After spending two weeks defining proper types, they discovered 47 bugs that TypeScript had been hiding—including API response mismatches and null reference errors.


Mistake #2: Skipping Runtime Validation for External Data

Here's a hard truth: TypeScript types don't exist at runtime. They vanish during compilation. This means API responses, form inputs, and URL parameters can still contain unexpected data.

The Problem

// ❌ Bad: Trusting API data implicitly
interface Product {
  id: string;
  price: number;
  inStock: boolean;
}

const ProductList = () => {
  const [products, setProducts] = useState<Product[]>([]);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(setProducts); // What if price is a string?
  }, []);

  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>
          ${p.price.toFixed(2)} {/* Runtime error if price is a string! */}
        </li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

The Fix

Use a validation library like Zod to verify data at runtime.

// ✅ Better: Validate at runtime with Zod
import { z } from 'zod';

const ProductSchema = z.object({
  id: z.string(),
  price: z.number().positive(),
  inStock: z.boolean(),
});

type Product = z.infer<typeof ProductSchema>;

const ProductList = () => {
  const [products, setProducts] = useState<Product[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        // Validate before using
        const validated = z.array(ProductSchema).parse(data);
        setProducts(validated);
      })
      .catch(err => {
        console.error('Validation failed:', err);
        setError('Invalid product data received');
      });
  }, []);

  if (error) return <div>{error}</div>;

  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>
          ${p.price.toFixed(2)} {/* Safe: we know price is a number */}
        </li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

Real-world scenario: An e-commerce site crashed on Black Friday because the inventory API started returning prices as strings like "19.99" instead of numbers. Runtime validation would have caught this immediately instead of crashing the checkout flow.


Mistake #3: Embedding API Calls in Components

Mixing data fetching directly into UI components creates a tangled mess that's hard to test, hard to reuse, and hard to debug.

The Problem

// ❌ Bad: Everything in one component
const UserDashboard = ({ userId }: { userId: string }) => {
  const [user, setUser] = useState<User | null>(null);
  const [posts, setPosts] = useState<Post[]>([]);
  const [settings, setSettings] = useState<Settings | null>(null);

  useEffect(() => {
    // Multiple fetches mixed with UI logic
    fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
    fetch(`/api/users/${userId}/posts`).then(res => res.json()).then(setPosts);
    fetch(`/api/users/${userId}/settings`).then(res => res.json()).then(setSettings);
  }, [userId]);

  // ... 200 more lines of rendering logic
};
Enter fullscreen mode Exit fullscreen mode

The Fix

Separate concerns with custom hooks.

// ✅ Better: Custom hooks for data fetching
const useUser = (userId: string) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetchUserData(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  return { user, loading, error };
};

// Now components are clean and focused
const UserDashboard = ({ userId }: { userId: string }) => {
  const { user, loading, error } = useUser(userId);
  const { posts } = useUserPosts(userId);
  const { settings } = useUserSettings(userId);

  if (loading) return <DashboardSkeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div>
      <UserProfile user={user!} />
      <PostList posts={posts} />
      <SettingsPanel settings={settings!} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Real-world scenario: A team spent three days debugging why a component sometimes showed stale data. The issue? An API call was buried 500 lines deep in a component. Extracting it to a custom hook made the caching strategy obvious and fixed the bug.


Mistake #4: Lazy Prop Typing

React's PropTypes are a runtime check, but they're inferior to TypeScript's compile-time checks. Even worse is not defining prop types at all.

The Problem

// ❌ Bad: Lazy or missing prop types
const Card = ({ title, items, onSelect }) => {
  return (
    <div>
      <h2>{title}</h2>
      <ul>
        {items.map(item => (
          <li key={item.id} onClick={() => onSelect(item)}>
            {item.name}
          </li>
        ))}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The Fix

Define explicit interfaces for your props.

// ✅ Better: Explicit prop types
interface CardItem {
  id: string;
  name: string;
  disabled?: boolean;
}

interface CardProps {
  title: string;
  items: CardItem[];
  onSelect: (item: CardItem) => void;
  variant?: 'primary' | 'secondary';
}

const Card = ({ 
  title, 
  items, 
  onSelect,
  variant = 'primary' 
}: CardProps) => {
  return (
    <div className={`card card--${variant}`}>
      <h2>{title}</h2>
      <ul>
        {items.map(item => (
          <li 
            key={item.id} 
            onClick={() => !item.disabled && onSelect(item)}
            style={{ opacity: item.disabled ? 0.5 : 1 }}
          >
            {item.name}
          </li>
        ))}
      </ul>
    </div>
  );
};

// Usage is now type-safe
<Card 
  title="Products" 
  items={products} 
  onSelect={handleSelect}
  variant="secondary"
/>
Enter fullscreen mode Exit fullscreen mode

Real-world scenario: A junior developer passed an array of numbers as items by accident. TypeScript caught it immediately because item.name wouldn't exist. Without proper prop types, this would have caused a runtime error in production.


Mistake #5: Ignoring Strict Mode and Compiler Warnings

TypeScript's strict mode is your safety net. Disabling it or ignoring warnings is like turning off your car's check engine light.

The Problem

//  Bad: tsconfig.json with strict disabled
{
  "compilerOptions": {
    "strict": false,  // Why even use TypeScript?
    "noImplicitAny": false,
    "strictNullChecks": false
  }
}
Enter fullscreen mode Exit fullscreen mode
// ❌ Bad: Suppressing warnings
// @ts-ignore
const result = someComplexOperation();

// @ts-expect-error
props.unknownMethod();
Enter fullscreen mode Exit fullscreen mode

The Fix

Enable strict mode and fix the underlying issues.

//  Better: Strict tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noUncheckedIndexedAccess": true
  }
}
Enter fullscreen mode Exit fullscreen mode
// ✅ Better: Handle edge cases properly
const processUser = (user: User | undefined) => {
  // TypeScript forces you to handle undefined
  if (!user) {
    throw new Error('User not found');
  }

  // Now TypeScript knows user is defined
  return user.name.toUpperCase();
};

// For genuinely complex types, use type guards
const isUser = (value: unknown): value is User => {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  );
};
Enter fullscreen mode Exit fullscreen mode

Real-world scenario: A production crash was traced to Cannot read property 'name' of undefined. The team had disabled strictNullChecks to "save time." Enabling it revealed 200+ potential null reference errors that were quietly lurking in the codebase.


Conclusion

TypeScript isn't a silver bullet—it's a tool. Like any tool, its effectiveness depends on how you use it. These five mistakes share a common theme: they're shortcuts that feel productive in the moment but create technical debt.

Here's the pattern to follow:

  1. Embrace types fully—no any escapes
  2. Validate at runtime—external data is untrusted
  3. Separate concerns—hooks over tangled components
  4. Define interfaces—explicit over implicit
  5. Enable strict mode—let the compiler catch your mistakes

The best TypeScript codebases aren't the ones with the most complex type gymnastics. They're the ones where the types are simple, clear, and actually helpful. Write types that would make sense to you six months from now, and your future self will thank you.


Have you encountered these mistakes in your own codebases? What other TypeScript patterns have you learned to avoid? Share your experiences in the comments below!

Top comments (0)