TypeScript + React - A Practical Guide That Won’t Make You Cry

From “any” Hell to Type-Safe Heaven (Without the PhD)

TypeScript seems intimidating. Generics, utility types, discriminated unions - it sounds like a computer science lecture. But once you get it, you’ll wonder how you ever lived without it.

The Bug That Convinced Me

I shipped a feature. QA tested it. Looked good. Went to production.

Five minutes later: “App is crashing for 20% of users.”

The bug? I typed user.adress instead of user.address. A simple typo. Took 30 seconds to fix, took 3 hours of firefighting to deploy.

TypeScript would have caught that in 0.5 seconds. That’s when I converted.

Why TypeScript with React?

Without TypeScript:

function UserProfile({ user, onUpdate }) {
  // What properties does user have?
  // What does onUpdate expect?
  // ¯\_(ツ)_/¯
  return <div>{user.name}</div>;
}

With TypeScript:

interface User {
  id: string;
  name: string;
  email: string;
}

interface Props {
  user: User;
  onUpdate: (user: User) => Promise<void>;
}

function UserProfile({ user, onUpdate }: Props) {
  return <div>{user.name}</div>;
}

Benefits:

  • Autocomplete shows you all available properties
  • Refactoring is safe (rename a property, TypeScript finds all usages)
  • Bugs caught at compile time, not runtime
  • Self-documenting code

Getting Started

# New project with Vite (faster)
npm create vite@latest my-app -- --template react-ts

Typing Components

// Direct typing (recommended)
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className={variant}>
      {label}
    </button>
  );
}

// Children
interface ContainerProps {
  children: React.ReactNode;
  className?: string;
}

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

Typing Hooks

// useState
const [count, setCount] = useState(0);  // inferred
const [user, setUser] = useState<User | null>(null);  // explicit

// useRef
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
  inputRef.current?.focus();
}, []);

// Custom hooks
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then((data: T) => {
        setData(data);
        setLoading(false);
      })
      .catch((err: Error) => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
}

Advanced: Discriminated Unions

// Instead of boolean flags
type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

function UserProfile() {
  const [state, setState] = useState<State>({ status: 'idle' });

  switch (state.status) {
    case 'idle':
      return <button onClick={fetchUser}>Load User</button>;
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <div>{state.data.name}</div>;  // TypeScript knows data exists
    case 'error':
      return <div>Error: {state.error.message}</div>;
  }
}

Utility Types

interface User {
  id: string;
  name: string;
  email: string;
  password: string;
}

type PublicUser = Omit<User, 'password'>;
type PartialUser = Partial<User>;  // All optional
type RequiredUser = Required<PartialUser>;  // All required
type UserRoles = Record<'admin' | 'user', boolean>;

Common Mistakes

1. Using any Everywhere

// DON'T
function handleData(data: any) {
  console.log(data.name);
}

// DO
interface Data {
  name: string;
}
function handleData(data: Data) {
  console.log(data.name);
}

2. Not Typing API Responses

// DON'T
const data = await fetch('/api/user').then(res => res.json());

// DO
interface ApiResponse {
  user: User;
  token: string;
}
const data: ApiResponse = await fetch('/api/user').then(res => res.json());

Quick Tips

  1. Start with strict: true - Pain now, pleasure later
  2. Use type inference - Don’t over-type
  3. Create types for API responses
  4. Use discriminated unions over boolean flags
  5. Avoid any - Use unknown if you don’t know the type
  6. Type component props - Even if simple

TypeScript feels like overkill until it saves you from a production bug at 2 AM.

Start small. Add types gradually. Your future self will thank you.

Happy typing!