Back to blog
Frontend Engineeringintermediate

TypeScript with React: Types, Generics & Production Patterns

Master TypeScript in React — prop typing, event handlers, generic components, discriminated unions, utility types, and patterns that eliminate runtime errors before they happen.

LearnixoApril 13, 202611 min read
ReactTypeScriptGenericsTypesPatternsType Safety
Share:𝕏

Why TypeScript Transforms React Development

TypeScript catches bugs at compile time — before your code runs. In React specifically:

  • Catch wrong prop types before runtime
  • Get autocomplete on props and event handlers
  • Refactor with confidence — TS tells you every call site that breaks
  • Self-documenting APIs — the types ARE the documentation

Typing Component Props

TSX
// Basic props
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;          // Optional: ? makes it undefined | boolean
  variant: "primary" | "secondary" | "danger";  // Union literal type
}

// Extending HTML element props — the correct way to build UI components
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "danger";
  loading?: boolean;
  icon?: React.ReactNode;
}

// Now ButtonProps has ALL native button props (disabled, type, onClick, etc.)
// plus your custom ones

function Button({ variant = "primary", loading, icon, children, ...rest }: ButtonProps) {
  return (
    <button {...rest}>  {/* ...rest spreads all native HTML props */}
      {icon}
      {children}
    </button>
  );
}

Event Handler Types

TSX
// Common event types you'll use daily
function FormExample() {
  // Input events
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

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

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

  // Form submission
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.currentTarget;
    const data = new FormData(form);
  };

  // Mouse events
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.stopPropagation();
  };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    const files = Array.from(e.dataTransfer.files);
  };

  // Keyboard events
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") handleSubmit(e as any);
    if (e.key === "Escape") clearInput();
  };

  // Focus events
  const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
    e.target.select();  // Select all text on focus
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        onFocus={handleFocus}
      />
    </form>
  );
}

Typing useState

TSX
// TypeScript infers simple types
const [count, setCount] = useState(0);           // number
const [name, setName] = useState("");            // string
const [active, setActive] = useState(false);     // boolean

// Provide explicit type when initial value is null/undefined
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);

// Complex initial state
interface FormState {
  email: string;
  password: string;
  rememberMe: boolean;
}

const [form, setForm] = useState<FormState>({
  email: "",
  password: "",
  rememberMe: false,
});

// Discriminated union state — when multiple shapes are possible
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

const [state, setState] = useState<AsyncState<User>>({ status: "idle" });

// TypeScript now knows which properties exist based on status
if (state.status === "success") {
  console.log(state.data);   // ✅ data is available
  // console.log(state.error); // ✅ TS error: error doesn't exist on success state
}

Generic Components — The Most Powerful Pattern

Generics let you write components that work with any data type while preserving type information.

TSX
// Generic List component — works with any item type
interface ListProps<T> {
  items: T[];
  keyExtractor: (item: T) => string;
  renderItem: (item: T) => React.ReactNode;
  emptyState?: React.ReactNode;
  className?: string;
}

function List<T>({
  items,
  keyExtractor,
  renderItem,
  emptyState,
  className,
}: ListProps<T>) {
  if (items.length === 0) {
    return emptyState ? <>{emptyState}</> : null;
  }

  return (
    <ul className={className}>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage: TypeScript infers T from the items prop
<List
  items={users}                  // T = User
  keyExtractor={(u) => u.id}     // u is User — autocomplete works!
  renderItem={(u) => (
    <UserCard user={u} />        // u is User — no type assertion needed
  )}
/>

<List
  items={products}               // T = Product
  keyExtractor={(p) => p.sku}    // p is Product
  renderItem={(p) => <ProductCard product={p} />}
  emptyState={<EmptyProducts />}
/>

Generic Select / Combobox

TSX
interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
  placeholder?: string;
}

function Select<T>({
  options,
  value,
  onChange,
  getLabel,
  getValue,
  placeholder = "Select...",
}: SelectProps<T>) {
  const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const selected = options.find((o) => getValue(o) === e.target.value);
    if (selected) onChange(selected);
  };

  return (
    <select value={value ? getValue(value) : ""} onChange={handleChange}>
      <option value="">{placeholder}</option>
      {options.map((option) => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}

// Usage
<Select
  options={countries}
  value={selectedCountry}
  onChange={setSelectedCountry}
  getLabel={(c) => c.name}    // c is Country — type safe!
  getValue={(c) => c.code}
/>

Discriminated Unions — Model State Correctly

This pattern eliminates impossible states and makes TypeScript verify your logic.

TSX
// ❌ Bad: any combination of these could be true simultaneously
interface BadState {
  loading: boolean;
  data: User | null;
  error: string | null;
}

// This is valid TypeScript but impossible in reality:
const state: BadState = { loading: true, data: someUser, error: "also an error" };

// ✅ Good: discriminated union — only valid combinations are possible
type UserState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; user: User }
  | { status: "error"; message: string };

function UserDisplay({ state }: { state: UserState }) {
  switch (state.status) {
    case "idle":
      return <button>Load User</button>;
    case "loading":
      return <Skeleton />;
    case "success":
      return <Profile user={state.user} />;  // TypeScript knows user exists here
    case "error":
      return <Alert message={state.message} />; // And message here
  }
  // TypeScript ensures you handled all cases (exhaustive check)
}

Real-World: Form Submission States

TSX
type SubmitState =
  | { kind: "idle" }
  | { kind: "submitting" }
  | { kind: "success"; message: string }
  | { kind: "error"; errors: Record<string, string> };

function ContactForm() {
  const [submitState, setSubmitState] = useState<SubmitState>({ kind: "idle" });

  const handleSubmit = async (data: FormData) => {
    setSubmitState({ kind: "submitting" });
    try {
      await api.post("/contact", data);
      setSubmitState({ kind: "success", message: "Message sent! We'll be in touch." });
    } catch (err: any) {
      setSubmitState({ kind: "error", errors: err.fieldErrors ?? {} });
    }
  };

  return (
    <form>
      {submitState.kind === "success" && (
        <SuccessBanner message={submitState.message} />
      )}
      {submitState.kind === "error" && (
        <FieldErrors errors={submitState.errors} />
      )}
      <button disabled={submitState.kind === "submitting"}>
        {submitState.kind === "submitting" ? "Sending..." : "Send"}
      </button>
    </form>
  );
}

Utility Types You'll Use Constantly

TSX
interface User {
  id: string;
  name: string;
  email: string;
  password: string;
  createdAt: string;
  role: "admin" | "user";
}

// Partial — all fields optional (good for update DTOs)
type UpdateUserDto = Partial<User>;

// Required — all fields required
type StrictUser = Required<User>;

// Pick — take specific fields
type UserPreview = Pick<User, "id" | "name" | "email">;
// Equivalent to: { id: string; name: string; email: string }

// Omit — remove specific fields
type PublicUser = Omit<User, "password">;
// Equivalent to: { id, name, email, createdAt, role }

// Readonly — prevent mutation
type ImmutableUser = Readonly<User>;

// Record — typed object map
type RolePermissions = Record<User["role"], string[]>;
// { admin: string[], user: string[] }

// ReturnType — type of what a function returns
type UserQueryResult = ReturnType<typeof useQuery<User>>;

// Parameters — types of function parameters
type FetchOptions = Parameters<typeof fetch>[1];

// NonNullable — remove null/undefined
type NonNullUser = NonNullable<User | null | undefined>;  // User

// Practical combinations
type CreateUserDto = Omit<User, "id" | "createdAt"> & {
  password: string;       // explicitly include password for creation
};

type UserCardProps = Pick<User, "name" | "email" | "role"> & {
  onEdit: () => void;
};

Typing useRef

TSX
// DOM element ref — always start as null
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);

// Type-safe DOM access
const focusInput = () => {
  inputRef.current?.focus();  // Optional chaining handles null
};

const getFormData = () => {
  if (formRef.current) {
    return new FormData(formRef.current);
  }
};

// Mutable ref (not a DOM element) — use MutableRefObject pattern
const countRef = useRef<number>(0);
countRef.current++;  // This is fine — no TypeScript error

const intervalRef = useRef<NodeJS.Timeout | null>(null);

Typing Context

TSX
interface ThemeContextType {
  theme: "light" | "dark" | "system";
  setTheme: (theme: "light" | "dark" | "system") => void;
  resolvedTheme: "light" | "dark";  // What's actually applied
}

// null default — forces consumers to verify the context was provided
const ThemeContext = createContext<ThemeContextType | null>(null);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark" | "system">("system");

  const resolvedTheme: "light" | "dark" = useMemo(() => {
    if (theme === "system") {
      return window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
    }
    return theme;
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Custom hook with type guard — throws a helpful error instead of silent null
function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within <ThemeProvider>");
  }
  return context;
}

// Usage — fully typed
function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  // resolvedTheme is "light" | "dark" — TypeScript knows this
  return (
    <button onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}>
      Toggle
    </button>
  );
}

Typing Custom Hooks

TSX
// Return a tuple (like useState)
function useToggle(initial = false): [boolean, () => void, (v: boolean) => void] {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle, setValue];
}

// Return an object (more flexible, no order dependency)
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

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

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json() as T;
      setData(json);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Unknown error");
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => { fetchData(); }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

// Usage — T is inferred from how you use .data
function UserPage({ id }: { id: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${id}`);
  // user is User | null — TypeScript knows the shape
}

forwardRef with TypeScript

TSX
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

// forwardRef needs two generics: <RefType, PropsType>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...props} />
        {error && <span>{error}</span>}
      </div>
    );
  }
);

Input.displayName = "Input";

// Usage
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} label="Email" error="Required" />

Type Guards — Runtime Safety

TSX
// Type guard function: returns type predicate
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "email" in value &&
    typeof (value as any).id === "string"
  );
}

// Usage: safely handle unknown API responses
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();

  if (!isUser(data)) {
    throw new Error("API returned unexpected shape");
  }

  return data;  // TypeScript knows this is User now
}

// Zod for runtime validation (better than manual type guards)
import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "user"]),
  createdAt: z.string(),
});

type User = z.infer<typeof UserSchema>;  // Type is derived from schema

async function fetchUserSafe(id: string): Promise<User> {
  const data = await fetch(`/api/users/${id}`).then((r) => r.json());
  return UserSchema.parse(data);  // Throws if shape doesn't match
}

Interview Questions Answered

Q: What is the difference between interface and type in TypeScript?

interface is extendable (open for declaration merging, extends with extends). type supports unions, intersections, mapped types, and conditional types — more powerful for complex type algebra. For React props: use interface for simple objects, type for unions or when you need Omit/Pick/Partial combinations. Either works; pick one and be consistent.

Q: What does React.FC do and should you use it?

React.FC<Props> was the standard way to type function components. It implicitly adds children to props and sets the return type. However, in modern React (17+), React.FC is often avoided because: (1) it adds implicit children even when you don't want it, (2) it doesn't support generics well. Prefer explicitly typing props with an interface and letting TypeScript infer the return type.

Q: How do you type a component that accepts any HTML element's props?

Use React.ComponentPropsWithoutRef<"div"> (or React.ComponentPropsWithRef<"div"> if forwarding refs). This gives you all the native props for that element. Combine with extends: interface CardProps extends React.ComponentPropsWithoutRef<"div"> { title: string; }.

Q: What is a discriminated union and why is it useful in React?

A discriminated union is a union of object types that share a common "discriminant" field (like status or kind). TypeScript uses it to narrow types in conditional branches. In React, it eliminates impossible states — instead of loading: boolean, data: T | null, error: string | null (which allows loading=true AND error simultaneously), you model it as { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: string } — only valid combinations exist.

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.