Strategic Software Engineer and Business Problem Solver - Merveille Alexandre
blogintermediate

10 TypeScript Tips Every React Developer Should Know

Level up your React development with these essential TypeScript patterns and techniques.

January 20, 2025
15 min read
Merveille Alexander

10 TypeScript Tips Every React Developer Should Know

Level up your React development with these essential TypeScript patterns and techniques


The Day TypeScript Stopped Being Scary

I remember the exact moment I decided to finally learn TypeScript. It was 3 PM on a rainy Tuesday, and I was debugging a React app that I hadn't touched in six months. A simple prop—just a boolean—was causing chaos somewhere deep in my component tree.

I stared at the error. Cannot read property 'map' of undefined. But I was sure I passed the data. I checked the parent component. The data was there. I checked the child component. The prop was there. I checked the grandchild component, and... oh. I had spelled isLoading as isLoadingg. One extra letter, three hours of my life gone forever.

"That's it," I shoutted. "I'm learning TypeScript."

Three years later, I could confidently say it was the best decision of my career. Not because TypeScript prevents typos (though it does). Not because it makes my code more "enterprise-ready" (whatever that means). But because TypeScript fundamentally changed how I think about React components.

These are the 10 tips I wish someone had told me on that rainy Tuesday.


Tip 1: Stop Using any – Really, Just Stop

I know. I know. When you're learning TypeScript, any feels like a life raft. You're drowning in red squiggles, and any promises to make them go away.

Code
// Please don't do this
const fetchUser = async (id: any): Promise<any> => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};

// Or this
const MyComponent = ({ data }: any) => {
  return <div>{data.name}</div>;
};

But here's the thing: using any is like putting a band-aid on a broken leg. It hides the problem without fixing it.

What To Do Instead

Code
// Define your types properly
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

const fetchUser = async (id: number): Promise<User> => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};

// Now React knows exactly what to expect
const UserProfile = ({ user }: { user: User }) => {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {user.role === 'admin' && <button>Delete User</button>}
    </div>
  );
};

See what happened? Now when I try to access user.address, TypeScript gently reminds me that addresses don't exist on my User type. When I pass user.id as a string to something expecting a number, TypeScript catches it before my users do.

The rule I follow now: If I'm tempted to use any, I take a deep breath and ask myself: "Do I really not know what this is?" Usually, I do know. I'm just being lazy. And laziness in typing leads to pain later.


Tip 2: Master type vs interface (It's Simpler Than You Think)

This debate has caused more Twitter arguments than pineapple on pizza. When should you use type and when should you use interface?

Here's the practical answer that actually matters for React development:

Use interface for objects and class-like structures

Code
// Great for props and state
interface UserProps {
  name: string;
  age: number;
  email?: string; // optional
}

// Great for extending
interface AdminProps extends UserProps {
  permissions: string[];
}

// Great for declaration merging (more on this later)

Use type for everything else

Code
// Unions
type Status = 'idle' | 'loading' | 'success' | 'error';

// Primitives
type UserID = string | number;

// Tuples
type Coordinates = [number, number];

// Utility types
type Nullable<T> = T | null;

// Function types
type ClickHandler = (event: React.MouseEvent) => void;

The React Props Rule of Thumb

For 95% of React components, this is all you need:

Code
// I use interface for props 90% of the time
interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'danger';
  onClick?: () => void;
  disabled?: boolean;
}

const Button = ({ 
  children, 
  variant = 'primary', 
  onClick, 
  disabled 
}: ButtonProps) => {
  return (
    <button 
      className={`btn btn-${variant}`}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

Simple, readable, and TypeScript knows exactly what's going on.


Tip 3: Use React.FC? Maybe Not

When I first started with TypeScript, every tutorial told me to use React.FC:

Code
// The old way
const Button: React.FC<ButtonProps> = ({ children, onClick }) => {
  return <button onClick={onClick}>{children}</button>;
};

But here's what nobody told me: React.FC has some quirks.

The problems:

  • It implicitly includes children (even when you don't want them)
  • It doesn't work well with generics
  • It makes default props behave strangely

What I use instead:

Code
// Just type the props directly
const Button = ({ children, onClick }: ButtonProps) => {
  return <button onClick={onClick}>{children}</button>;
};

// For components that need to be super explicit
function Button({ children, onClick }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
}

Both work perfectly. Both are clear. Both play nicely with TypeScript.


Tip 4: Extract Union Types from Constants

This pattern saves me hours of maintenance every month. When you have constants, don't write separate types—derive them.

The Bad Way

Code
// You're maintaining the same list in two places
const buttonVariants = ['primary', 'secondary', 'danger'] as const;
type ButtonVariant = 'primary' | 'secondary' | 'danger'; // Duplication!

Now when you add a new variant, you have to update two things. Eventually you'll forget, and TypeScript will yell at you.

The Good Way

Code
// Define your constants
export const BUTTON_VARIANTS = ['primary', 'secondary', 'danger'] as const;

// Derive the type from the constant
export type ButtonVariant = typeof BUTTON_VARIANTS[number];
// Result: 'primary' | 'secondary' | 'danger'

// Use them together
interface ButtonProps {
  variant?: ButtonVariant;
  children: React.ReactNode;
}

const Button = ({ variant = 'primary', children }: ButtonProps) => {
  return (
    <button className={`btn btn-${variant}`}>
      {children}
    </button>
  );
};

// In another file, you can map through the constants
const VariantShowcase = () => (
  <div>
    {BUTTON_VARIANTS.map(variant => (
      <Button key={variant} variant={variant}>
        {variant} button
      </Button>
    ))}
  </div>
);

One source of truth. TypeScript infers everything. Maintenance goes down, confidence goes up.


Tip 5: Type Your Event Handlers Properly

Nothing made me feel more like a beginner than trying to type event handlers. The syntax looked like alien language:

Code
// What is this?!
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {}}

But once I understood the pattern, it became second nature.

The Pattern

Code
// Form elements -> ChangeEvent
<input 
  onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }}
/>

<select 
  onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
    setSelected(e.target.value);
  }}
>
  {/* options */}
</select>

// Buttons, divs -> MouseEvent
<button 
  onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    handleClick();
  }}
>
  Click me
</button>

// Forms -> FormEvent
<form 
  onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    submitForm();
  }}
>
  {/* form fields */}
</form>

The Clean Way

Better yet, extract your handlers:

Code
const SearchInput = () => {
  const [query, setQuery] = useState('');
  
  // TypeScript infers the event type automatically
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
  };
  
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    searchAPI(query);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />
      <button type="submit">Search</button>
    </form>
  );
};

Clean, readable, and fully typed.


Tip 6: Use Discriminated Unions for Complex State

This pattern changed how I handle complex component states. Instead of boolean flags everywhere, use a discriminated union.

The Messy Way

Code
// So many booleans, so many impossible states
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');

// What happens if isLoading and isError are both true? 
// What if we have data but also an error?
// Chaos, that's what.

The Clean Way

Code
// One type to rule them all
type DataState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function DataFetcher({ url }: { url: string }) {
  const [state, setState] = useState<DataState<any>>({ status: 'idle' });
  
  useEffect(() => {
    setState({ status: 'loading' });
    
    fetch(url)
      .then(res => res.json())
      .then(data => setState({ status: 'success', data }))
      .catch(err => setState({ status: 'error', error: err.message }));
  }, [url]);
  
  // TypeScript knows exactly what's available in each state
  switch (state.status) {
    case 'idle':
      return <div>Ready to fetch</div>;
      
    case 'loading':
      return <Spinner />;
      
    case 'success':
      // TypeScript knows data exists here
      return <DisplayData data={state.data} />;
      
    case 'error':
      // TypeScript knows error exists here
      return <ErrorMessage message={state.error} />;
  }
}

This is beautiful. Impossible states are impossible. TypeScript guides you through every branch. Your coworkers will thank you.


Tip 7: Leverage TypeScript's Utility Types

TypeScript comes with built-in utility types that save enormous amounts of code. Here are the ones I use daily:

Pick and Omit

Code
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
  updatedAt: Date;
}

// For a profile display, omit sensitive fields
type PublicUser = Omit<User, 'password' | 'createdAt' | 'updatedAt'>;

// For a user card, pick only what you need
type UserCardProps = Pick<User, 'id' | 'name'>;

const UserCard = ({ id, name }: UserCardProps) => {
  return (
    <div>
      <h3>{name}</h3>
      <Link to={`/users/${id}`}>View Profile</Link>
    </div>
  );
};

Partial and Required

Code
interface FormData {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
}

// For form updates, everything is optional
const [formData, setFormData] = useState<Partial<FormData>>({});

// For submission, everything must be there
const handleSubmit = (data: Required<FormData>) => {
  // submit logic
};

// When editing, some fields might be required, some optional
interface EditUserForm {
  id: number; // required
  name?: string; // optional
  email?: string; // optional
}

Record for Dictionaries

Code
// Instead of { [key: string]: User }
const usersById: Record<string, User> = {
  '1': { id: 1, name: 'John', email: 'john@example.com' },
  '2': { id: 2, name: 'Jane', email: 'jane@example.com' },
};

// With specific keys
type UserRole = 'admin' | 'moderator' | 'user';
const rolePermissions: Record<UserRole, string[]> = {
  admin: ['read', 'write', 'delete'],
  moderator: ['read', 'write'],
  user: ['read'],
};

Tip 8: Type Your Custom Hooks

Custom hooks are where TypeScript really shines. A well-typed hook documents itself.

Before: Guesswork

Code
function useLocalStorage(key, initialValue) {
  // What does this return? No idea.
  // Gotta read the implementation.
}

After: Self-Documenting

Code
function useLocalStorage<T>(
  key: string, 
  initialValue: T
): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });
  
  const setValue = (value: T) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.log(error);
    }
  };
  
  return [storedValue, setValue];
}

// Now using it is crystal clear
function SettingsPage() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>(
    'theme', 
    'light'
  );
  
  const [fontSize, setFontSize] = useLocalStorage<number>(
    'fontSize', 
    16
  );
  
  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      
      <input 
        type="number" 
        value={fontSize}
        onChange={(e) => setFontSize(parseInt(e.target.value))}
      />
    </div>
  );
}

The generic <T> makes this hook work with any type. The return type [T, (value: T) => void] tells you exactly how to use it.


Tip 9: Use satisfies for Better Type Inference (TypeScript 4.9+)

This is a newer feature that solves a common frustration. Sometimes you want TypeScript to check that a value matches a type, but you also want the most specific inference possible.

The Problem

Code
type Route = {
  path: string;
  component: React.ComponentType;
  permissions?: string[];
};

const routes: Record<string, Route> = {
  home: {
    path: '/',
    component: HomePage,
    // No permissions here - fine
  },
  admin: {
    path: '/admin',
    component: AdminPage,
    permissions: ['admin'], // This should be specific
  }
};

// When we use routes.admin.permissions, TypeScript only knows it's
// string[] | undefined, not the specific ['admin'] array

The Solution with satisfies

Code
const routes = {
  home: {
    path: '/',
    component: HomePage,
  },
  admin: {
    path: '/admin',
    component: AdminPage,
    permissions: ['admin'] as const, // Now it's specifically ['admin']
  },
} satisfies Record<string, Route>;

// TypeScript knows routes.admin.permissions is ['admin']
// But it also checks that everything matches the Route type

This is perfect for configuration objects, route definitions, and any time you want both type checking AND precise inference.


Tip 10: Don't Over-Optimize Types

This is the most important tip. TypeScript is a tool to help you, not a religion to worship.

When NOT to Use TypeScript's Advanced Features

Code
// You COULD do this...
type DeepReadonly<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
};

// But do you NEED to?
interface SimpleProps {
  name: string;
  age: number;
}

// This is probably fine
const MyComponent = ({ name, age }: SimpleProps) => {
  return <div>{name} is {age} years old</div>;
};

My Philosophy on TypeScript Complexity

Do type:

  • Props and state
  • API responses
  • Complex business logic
  • Custom hooks
  • Utility functions

Don't obsess over:

  • Perfectly typing every callback
  • Deeply nested generics
  • Conditional types for simple logic
  • Making everything immutable

The 80/20 Rule

Code
// Good enough
function processItems<T>(items: T[], filter: (item: T) => boolean): T[] {
  return items.filter(filter);
}

// Probably overkill for most apps
type ProcessItemsReturnType<T> = T extends Array<infer U> ? U[] : never;
function processItems<T, F extends (item: T) => boolean>(
  items: T[], 
  filter: F
): ProcessItemsReturnType<T> {
  // ... implementation
}

The first version is readable, maintainable, and catches the important errors. The second version might impress your TypeScript friends, but it'll confuse everyone else (including you in six months).


Putting It All Together: A Real Component

Let's see these tips in action with a real-world example:

Code
import { useState } from 'react';

// Tip 4: Derive types from constants
export const BUTTON_SIZES = ['small', 'medium', 'large'] as const;
export const BUTTON_VARIANTS = ['primary', 'secondary', 'outline'] as const;

export type ButtonSize = typeof BUTTON_SIZES[number];
export type ButtonVariant = typeof BUTTON_VARIANTS[number];

// Tip 2: Interface for props
export interface ButtonProps {
  children: React.ReactNode;
  size?: ButtonSize;
  variant?: ButtonVariant;
  disabled?: boolean;
  loading?: boolean;
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  type?: 'button' | 'submit' | 'reset';
}

// Tip 3: Regular function with typed props
export function Button({ 
  children, 
  size = 'medium',
  variant = 'primary',
  disabled = false,
  loading = false,
  onClick,
  type = 'button'
}: ButtonProps) {
  // Tip 5: Properly typed event handler
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (!disabled && !loading && onClick) {
      onClick(e);
    }
  };
  
  return (
    <button
      type={type}
      onClick={handleClick}
      disabled={disabled || loading}
      className={`btn btn-${size} btn-${variant}`}
    >
      {loading ? <Spinner /> : children}
    </button>
  );
}

// Tip 6: Discriminated union for async state
type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

// Tip 8: Generic custom hook
function useAsync<T>(asyncFn: () => Promise<T>) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
  
  const execute = async () => {
    setState({ status: 'loading' });
    
    try {
      const data = await asyncFn();
      setState({ status: 'success', data });
    } catch (err) {
      setState({ status: 'error', error: String(err) });
    }
  };
  
  return { ...state, execute };
}

// Using everything together
const SubmitButton = () => {
  const { status, execute } = useAsync(async () => {
    const response = await fetch('/api/submit', { method: 'POST' });
    return response.json();
  });
  
  return (
    <Button
      variant="primary"
      size="large"
      loading={status === 'loading'}
      onClick={execute}
      type="submit"
    >
      {status === 'error' ? 'Try Again' : 'Submit Form'}
    </Button>
  );
};

What I Wish I Knew When I Started

Learning TypeScript felt overwhelming at first. The error messages were cryptic. The syntax was foreign. I kept thinking, "Is this really worth it?"

Three years later, I can't imagine going back.

Here's what I know now that I wish I knew then:

Start simple. Type the easy stuff first. Props, state, simple functions. The advanced patterns will come naturally as you need them.

Let TypeScript guide you. When you see red squiggles, don't just add any. Read the error message. Try to understand what TypeScript is telling you. Often, it's catching a real bug.

Use an IDE with good TypeScript support. VS Code is free and amazing. The autocomplete, the refactoring tools, the instant feedback—it's worth its weight in gold.

TypeScript is for humans, not just the compiler. The best types make your code self-documenting. When someone reads your component, they should understand what it needs without reading the implementation.

You'll still make mistakes. TypeScript doesn't guarantee bug-free code. But it catches the dumb mistakes—the typos, the undefined values, the wrong function calls—so you can focus on the interesting bugs.


That rainy Tuesday when I decided to learn TypeScript turned out to be one of the best days of my career. Not because TypeScript made me a genius. But because it made me a more confident developer. When I push code now, I sleep better at night. When I refactor, I move faster. When I onboard new team members, they understand the codebase sooner.

TypeScript didn't make me smarter. It just eliminated the distractions so I could focus on what matters: building things that people actually use.


Enjoyed this post? I write about React, TypeScript, and real-world development every Month. Follow me on X @themarvelbiz.


Written by Merveille Alexander

Software Engineer sharing knowledge and experience.