iZONE

React Cheatsheet

A practical React guide covering components, hooks, state, context, and server features.

Resources

JSX

JSX is the HTML-like syntax you write inside React components. It looks like HTML but has a few important differences worth knowing upfront.

JSX rules

TypeScript

function Card() {
  return (
    <>
      {/* className not class */}
      <h2 className="title">Hello</h2>

      {/* style is an object */}
      <p style={{ color: "red", fontSize: "16px" }}>
        Text
      </p>

      {/* all tags must close */}
      <img src="/logo.png" alt="logo" />
      <input type="text" />
    </>
  );
}

JavaScript inside JSX

TypeScript

function Profile() {
  const name    = "Alice";
  const age     = 30;
  const isAdmin = true;

  return (
    <div>
      {/* variable */}
      <p>{name}</p>

      {/* calculation */}
      <p>Born in {2024 - age}</p>

      {/* ternary */}
      <p>{isAdmin ? "Admin" : "User"}</p>

      {/* show only if true */}
      {isAdmin && <button>Admin Panel</button>}
    </div>
  );
}

rendering a list

TypeScript

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob"   },
  { id: 3, name: "Carol" },
];

function UserList() {
  return (
    <ul>
      {users.map((user) => (
        // use item's ID, not array index
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

conditional rendering

TypeScript

function Status({ isLoggedIn }: { isLoggedIn: boolean }) {
  // early return — cleanest for big differences
  if (!isLoggedIn) return <p>Please log in.</p>;

  return (
    <div>
      {/* ternary — two outcomes */}
      {isLoggedIn
        ? <p>Welcome back!</p>
        : <p>Please log in.</p>
      }

      {/* && — show or nothing */}
      {isLoggedIn && <button>Log out</button>}
    </div>
  );
}

Props

Props are how you pass data into a component — like arguments to a function. A component can never change its own props.

passing and receiving props

TypeScript

interface ButtonProps {
  label:     string;
  variant?:  "primary" | "outline";
  disabled?: boolean;
  onClick?:  () => void;
}

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

<Button label="Save" onClick={() => console.log("saved")} />
<Button label="Cancel" variant="outline" />

children — pass content between tags

TypeScript

import { type ReactNode } from "react";

function Card({ children, title }: {
  children: ReactNode;
  title?:   string;
}) {
  return (
    <div className="card">
      {title && <h3>{title}</h3>}
      {children}
    </div>
  );
}

// anything between tags becomes children
<Card title="Welcome">
  <p>This is the children.</p>
  <button>Click me</button>
</Card>

spread native HTML attributes

TypeScript

import { type ComponentPropsWithoutRef } from "react";

interface ButtonProps
  extends ComponentPropsWithoutRef<"button"> {
  variant?: "primary" | "outline";
}

function Button({
  variant = "primary",
  ...rest
}: ButtonProps) {
  return <button className={variant} {...rest} />;
}

// all native button attributes work automatically
<Button
  type="submit"
  disabled
  aria-label="Save form"
>
  Save
</Button>

useState

useState lets a component remember a value between renders. Every time you update state, React re-renders the component with the new value.

useState basics

TypeScript

import { useState } from "react";

function Counter() {
  // [current, updater] = useState(startValue)
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Add 1
      </button>
      <button onClick={() => setCount(count - 1)}>
        Minus 1
      </button>
      <button onClick={() => setCount(0)}>
        Reset
      </button>
    </div>
  );
}

updating from previous value

TypeScript

// ❌ can cause bugs if called quickly
setCount(count + 1);

// ✅ prev is always the latest value
setCount((prev) => prev + 1);
setCount((prev) => prev * 2);

// toggling a boolean
const [isOpen, setIsOpen] = useState(false);
setIsOpen((prev) => !prev);

Whenever new state depends on the previous value, use `setCount(prev => prev + 1)` — not `setCount(count + 1)`.

object state — never mutate directly

TypeScript

const [user, setUser] = useState({
  name: "Alice",
  age: 30
});

// ✅ spread to create new object
setUser((prev) => ({ ...prev, age: 31 }));

// ❌ doesn't trigger re-render
// user.age = 31;

array state — never mutate directly

TypeScript

const [items, setItems] = useState(["apple", "banana"]);

// add an item
setItems((prev) => [...prev, "cherry"]);

// remove an item
setItems((prev) =>
  prev.filter((item) => item !== "banana")
);

// update one item
setItems((prev) =>
  prev.map((item) =>
    item === "apple" ? "APPLE" : item
  )
);

lazy initialisation

TypeScript

// ❌ reads localStorage on EVERY render
const [theme, setTheme] = useState(
  localStorage.getItem("theme") ?? "light"
);

// ✅ function runs ONCE on first render only
const [theme, setTheme] = useState(
  () => localStorage.getItem("theme") ?? "light"
);

Pass a function to useState when the initial value is expensive to compute — it only runs once on the first render.

useEffect

useEffect runs code after the component renders — perfect for fetching data, setting up timers, or syncing with something outside React.

when does useEffect run?

TypeScript

import { useEffect } from "react";

// runs after every render
useEffect(() => {
  console.log("rendered");
});

// runs ONCE on mount
useEffect(() => {
  console.log("mounted");
}, []);

// runs when userId changes
useEffect(() => {
  fetchUser(userId);
}, [userId]);

cleanup — stop things when component disappears

TypeScript

// event listener cleanup
useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

// cancel fetch when id changes
useEffect(() => {
  const controller = new AbortController();

  async function load() {
    const res = await fetch(`/api/users/${id}`, {
      signal: controller.signal,
    });
    setUser(await res.json());
  }

  load();

  return () => controller.abort();
}, [id]);

fetching data in useEffect

TypeScript

import { useState, useEffect } from "react";

function UserProfile({ id }: { id: number }) {
  const [user,    setUser]    = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then((data) => setUser(data))
      .catch((e)   => setError(e.message))
      .finally(()  => setLoading(false));
  }, [id]);

  if (loading) return <p>Loading…</p>;
  if (error)   return <p>Error: {error}</p>;
  return <p>{user?.name}</p>;
}

More Hooks

React has several other built-in hooks. These are the ones you'll reach for most often after useState and useEffect.

useRef — DOM access or persistent value

TypeScript

import { useRef, useEffect } from "react";

// DOM ref — focus on mount
function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="Auto-focused" />;
}

// value store — no re-render when changed
const timerRef = useRef<number | null>(null);

function start() {
  timerRef.current = setInterval(() => tick(), 1000);
}
function stop() {
  if (timerRef.current) clearInterval(timerRef.current);
}

useCallback — stable function reference

TypeScript

import { useCallback } from "react";

function Parent({ userId }: { userId: number }) {
  // ❌ new function every render
  const handleClick = () => {
    console.log("clicked", userId);
  };

  // ✅ same reference between renders
  const handleClick = useCallback(() => {
    console.log("clicked", userId);
  }, [userId]);
  // only recreates when userId changes

  return <Child onClick={handleClick} />;
}

Don't add useCallback everywhere 'just in case' — only use it when you've actually noticed a performance problem.

useMemo — cache an expensive calculation

TypeScript

import { useMemo } from "react";

function FilteredList({
  items,
  query
}: {
  items: string[];
  query: string
}) {
  // ❌ filters on every render
  const filtered = items.filter((i) => i.includes(query));

  // ✅ only re-filters when items or query changes
  const filtered = useMemo(
    () => items.filter((i) => i.includes(query)),
    [items, query]
  );

  return (
    <ul>
      {filtered.map((i) => <li key={i}>{i}</li>)}
    </ul>
  );
}

Don't add useMemo everywhere — only use it when you've measured a real slowdown.

useId — generate unique IDs

TypeScript

import { useId } from "react";

function FormField({ label }: { label: string }) {
  const id = useId();
  // e.g. ":r1:"

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  );
}

// Safe to render multiple times — each gets its own ID
<FormField label="Name" />
<FormField label="Email" />

useDeferredValue — deprioritise a slow update

TypeScript

import { useState, useDeferredValue } from "react";

function SearchPage() {
  const [query, setQuery] = useState("");

  // query updates instantly
  // deferredQuery updates only when React has spare time
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />

      {/* uses deferred value — won't block the input */}
      <SlowResultsList query={deferredQuery} />
    </>
  );
}

useReducer — manage complex state

TypeScript

import { useReducer } from "react";

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" };

function reducer(count: number, action: Action): number {
  switch (action.type) {
    case "increment": return count + 1;
    case "decrement": return count - 1;
    case "reset":     return 0;
  }
}

function Counter() {
  const [count, dispatch] = useReducer(reducer, 0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>
        +
      </button>
      <button onClick={() => dispatch({ type: "decrement" })}>
        -
      </button>
      <button onClick={() => dispatch({ type: "reset" })}>
        Reset
      </button>
    </div>
  );
}

Context API

Context lets you share a value with any component in your app — without passing it through props at every level in between.

the problem context solves

TypeScript

// ❌ theme passes through every level uselessly
function App()     { return <Layout theme={theme} />; }
function Layout()  { return <Sidebar theme={theme} />; }
function Sidebar() { return <Button theme={theme} />; }
// Button is the only one that needs it

// ✅ with context — Button reads it directly

create, provide, and consume context

TypeScript

import {
  createContext,
  useState,
  useContext
} from "react";

interface ThemeContextType {
  theme:  "light" | "dark";
  toggle: () => void;
}

// 1. create
export const ThemeContext =
  createContext<ThemeContextType>({
    theme:  "light",
    toggle: () => {},
  });

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

  const toggle = () =>
    setTheme((prev) =>
      prev === "light" ? "dark" : "light"
    );

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

// 3. custom hook to consume
export function useTheme() {
  return useContext(ThemeContext);
}

// any component inside Provider can use it
function Header() {
  const { theme, toggle } = useTheme();
  return (
    <header>
      <button onClick={toggle}>
        Toggle {theme}
      </button>
    </header>
  );
}

Custom Hooks

A custom hook is a function whose name starts with use that packages reusable logic — so you don't repeat the same useState + useEffect pattern across multiple components.

useLocalStorage — persist state across reloads

TypeScript

import { useState, useEffect } from "react";

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored
        ? JSON.parse(stored)
        : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// works just like useState but survives page reloads
const [theme, setTheme] =
  useLocalStorage("theme", "light");

const [name, setName] =
  useLocalStorage("username", "");

useDebounce — wait until the user stops typing

TypeScript

import { useState, useEffect } from "react";

function useDebounce<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(
      () => setDebounced(value),
      delayMs
    );
    // cancel if value changes before timer fires
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

// usage
function SearchBox() {
  const [query, setQuery] = useState("");

  // waits 300ms after typing stops
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return (
    <input onChange={(e) => setQuery(e.target.value)} />
  );
}

useWindowSize — track viewport dimensions

TypeScript

import { useState, useEffect } from "react";

function useWindowSize() {
  const [size, setSize] = useState({
    width:  window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({
        width:  window.innerWidth,
        height: window.innerHeight,
      });
    }

    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return size;
}

// usage
function Banner() {
  const { width } = useWindowSize();
  return (
    <p>
      {width < 768 ? "Mobile view" : "Desktop view"}
    </p>
  );
}

useFetch — reusable data loading

TypeScript

import { useState, useEffect } from "react";

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);

    fetch(url, { signal: controller.signal })
      .then((r) => r.json())
      .then((d: T) => {
        setData(d);
        setLoading(false);
      })
      .catch((e) => {
        if (e.name !== "AbortError") {
          setError(e.message);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// three lines instead of twenty
function UserPage({ id }: { id: number }) {
  const {
    data: user,
    loading,
    error
  } = useFetch<User>(`/api/users/${id}`);

  if (loading) return <p>Loading…</p>;
  if (error)   return <p>Error: {error}</p>;
  return <p>{user?.name}</p>;
}

Forms & Inputs

React forms come in two styles: controlled (state drives the input) and uncontrolled (the DOM drives the input). Controlled is the most common approach.

controlled input — state drives the value

TypeScript

import { useState } from "react";

function LoginForm() {
  const [email,    setEmail]    = useState("");
  const [password, setPassword] = useState("");
  const [error,    setError]    = useState("");

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email) {
      setError("Email is required");
      return;
    }
    console.log("Logging in:", email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {error && <p style={{ color: "red" }}>{error}</p>}
      <button type="submit">Log in</button>
    </form>
  );
}

one state object for many fields

TypeScript

function SignupForm() {
  const [form, setForm] = useState({
    name:  "",
    email: "",
    role:  "user"
  });

  function handleChange(
    e: React.ChangeEvent
      HTMLInputElement | HTMLSelectElement
    >
  ) {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
    // [name] = computed key
  }

  return (
    <form>
      {/* name must match the key in form state */}
      <input
        name="name"
        value={form.name}
        onChange={handleChange}
      />
      <input
        name="email"
        value={form.email}
        onChange={handleChange}
      />
      <select
        name="role"
        value={form.role}
        onChange={handleChange}
      >
        <option value="user">User</option>
        <option value="admin">Admin</option>
      </select>
    </form>
  );
}

uncontrolled input — read value on submit

TypeScript

import { useRef } from "react";

function SearchForm() {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const query = inputRef.current?.value ?? "";
    console.log("Searching:", query);
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* defaultValue sets starting value — not controlled */}
      <input
        ref={inputRef}
        defaultValue=""
        placeholder="Search…"
      />
      <button type="submit">Search</button>
    </form>
  );
}

Component Patterns

These patterns show up in almost every real React codebase. Worth knowing so you can recognise and use them confidently.

compound components

TypeScript

function Tabs({
  children
}: {
  children: React.ReactNode
}) {
  return <div className="tabs">{children}</div>;
}

Tabs.List = function TabsList({
  children
}: {
  children: React.ReactNode
}) {
  return <div className="tabs-list">{children}</div>;
};

Tabs.Panel = function TabsPanel({
  children
}: {
  children: React.ReactNode
}) {
  return <div className="tabs-panel">{children}</div>;
};

// compose in any order
<Tabs>
  <Tabs.List>
    <button>Tab 1</button>
    <button>Tab 2</button>
  </Tabs.List>
  <Tabs.Panel>Content 1</Tabs.Panel>
  <Tabs.Panel>Content 2</Tabs.Panel>
</Tabs>

controlled component pattern

TypeScript

// no internal state — fully controlled by parent
function VolumeSlider({
  value,
  onChange,
}: {
  value:    number;
  onChange: (v: number) => void;
}) {
  return (
    <input
      type="range" min={0} max={100}
      value={value}
      onChange={(e) => onChange(Number(e.target.value))}
    />
  );
}

// parent owns the state
function App() {
  const [volume, setVolume] = useState(50);
  return (
    <div>
      <VolumeSlider value={volume} onChange={setVolume} />
      <p>Volume: {volume}</p>
    </div>
  );
}

forwardRef — pass a ref through a component

TypeScript

import { forwardRef } from "react";

// React 18 and below
const Input = forwardRef
  HTMLInputElement,
  React.ComponentPropsWithoutRef<"input">
>(({ className, ...props }, ref) => (
  <input ref={ref} className={className} {...props} />
));

// React 19+ — ref works as a normal prop
function Input({ ref, className, ...props }: {
  ref?: React.Ref<HTMLInputElement>;
} & React.ComponentPropsWithoutRef<"input">) {
  return (
    <input ref={ref} className={className} {...props} />
  );
}

// parent gets direct access either way
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return (
    <div>
      <Input ref={inputRef} placeholder="Type here" />
      <button onClick={() => inputRef.current?.focus()}>
        Focus
      </button>
    </div>
  );
}

React 19 lets you pass `ref` as a regular prop — no need for `forwardRef` in new projects.

Performance

React is fast by default. Only reach for these when you've actually noticed a slowdown — don't sprinkle them everywhere just in case.

React.memo — skip re-rendering unchanged components

TypeScript

import { memo } from "react";

const Item = memo(function Item({
  name,
  onClick,
}: {
  name:    string;
  onClick: () => void;
}) {
  console.log("rendered:", name);
  // you'll see this less often with memo
  return <li onClick={onClick}>{name}</li>;
});

// ⚠️ pair with useCallback on the parent
// otherwise a new onClick every render defeats memo

useTransition — keep UI responsive during slow updates

TypeScript

import { useState, useTransition } from "react";

function SearchResults() {
  const [query,   setQuery]   = useState("");
  const [results, setResults] = useState<string[]>([]);

  const [isPending, startTransition] = useTransition();

  function handleInput(
    e: React.ChangeEvent<HTMLInputElement>
  ) {
    // urgent — update input immediately
    setQuery(e.target.value);

    // non-urgent — can wait
    startTransition(() => {
      setResults(expensiveSearch(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleInput} />
      {isPending && <p>Searching…</p>}
      <ul>
        {results.map((r) => <li key={r}>{r}</li>)}
      </ul>
    </>
  );
}

lazy loading — load components only when needed

TypeScript

import { lazy, Suspense } from "react";

// not in the initial bundle
const Dashboard = lazy(() => import("./Dashboard"));
const Settings  = lazy(() => import("./Settings"));

function App({ page }: { page: string }) {
  return (
    // shows fallback while downloading
    <Suspense fallback={<p>Loading page…</p>}>
      {page === "dashboard"
        ? <Dashboard />
        : <Settings />
      }
    </Suspense>
  );
}

Error Boundaries

An error boundary catches errors inside child components and shows a fallback instead of crashing the whole page.

class-based error boundary

TypeScript

import { Component, type ReactNode } from "react";

class ErrorBoundary extends Component
  { children: ReactNode; fallback: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    console.error("Crashed:", error.message);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

// wrap any section to protect it
<ErrorBoundary
  fallback={<p>This section couldn't load.</p>}
>
  <RiskyComponent />
</ErrorBoundary>

react-error-boundary — easier setup

TypeScript

// npm install react-error-boundary
import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({
  error,
  resetErrorBoundary,
}: {
  error:              Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>
        Try again
      </button>
    </div>
  );
}

<ErrorBoundary FallbackComponent={ErrorFallback}>
  <App />
</ErrorBoundary>

Use the `react-error-boundary` package for most projects — it's simpler and adds a reset button out of the box.

React 19 — New Features

React 19 added new hooks and simplified some existing patterns. Form handling, optimistic updates, and data loading all got noticeably easier.

useActionState — manage form submission

TypeScript

import { useActionState } from "react";

async function submitForm(
  _prevState: unknown,
  formData: FormData
) {
  const name = formData.get("name") as string;
  await saveToServer(name);
  return { success: true, name };
}

function ContactForm() {
  const [state, action, isPending] =
    useActionState(submitForm, null);

  return (
    <form action={action}>
      <input name="name" placeholder="Your name" />
      <button disabled={isPending}>
        {isPending ? "Saving…" : "Save"}
      </button>
      {state?.success && (
        <p>Saved! Hello {state.name}.</p>
      )}
    </form>
  );
}

useOptimistic — instant feedback before server responds

TypeScript

import { useOptimistic } from "react";

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] =
    useOptimistic(
      todos,
      (current, newTodo: Todo) => [...current, newTodo]
    );

  async function handleAdd(text: string) {
    const temp = { id: Date.now(), text, done: false };

    // show immediately — feels instant
    addOptimisticTodo(temp);

    // save in the background
    await saveTodo(text);
    // React replaces with real data when done
  }

  return (
    <ul>
      {optimisticTodos.map((t) => (
        <li key={t.id}>{t.text}</li>
      ))}
    </ul>
  );
}

use() — read a Promise or context inside render

TypeScript

import { use, Suspense } from "react";

// read a Promise directly in render
function UserCard({
  userPromise
}: {
  userPromise: Promise<User>
}) {
  // suspends until resolved
  const user = use(userPromise);
  return <p>{user.name}</p>;
}

// wrap with Suspense for the loading state
function App() {
  const userPromise = fetchUser(1);
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

// also works as a conditional context reader
function Component({ condition }: { condition: boolean }) {
  if (condition) {
    // useContext can't go inside an if — use() can
    const theme = use(ThemeContext);
    return <p>{theme}</p>;
  }
  return null;
}

ref as a prop — no more forwardRef

TypeScript

// React 18 — had to use forwardRef
const Input = forwardRef<HTMLInputElement, Props>(
  (props, ref) => <input ref={ref} {...props} />
);

// React 19 — ref is just a prop now
function Input({
  ref,
  ...props
}: React.ComponentPropsWithoutRef<"input"> & {
  ref?: React.Ref<HTMLInputElement>;
}) {
  return <input ref={ref} {...props} />;
}

// usage is identical either way
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} placeholder="Type here" />

TypeScript with React

TypeScript catches bugs before they happen. These are the types and patterns you'll use most in a React + TypeScript project.

the most useful React types

TypeScript

import {
  type ReactNode,
  // anything React can render

  type ReactElement,
  // specifically a JSX element

  type CSSProperties,
  // the style prop object

  type ComponentPropsWithoutRef,
  // all native HTML attributes
} from "react";

function Wrapper({ children }: { children: ReactNode }) {
  return <div>{children}</div>;
}

typing event handlers

TypeScript

// input change
function handleChange(
  e: React.ChangeEvent<HTMLInputElement>
) {
  console.log(e.target.value);
}

// form submit
function handleSubmit(
  e: React.FormEvent<HTMLFormElement>
) {
  e.preventDefault();
}

// button click
function handleClick(
  e: React.MouseEvent<HTMLButtonElement>
) {
  console.log("at:", e.clientX, e.clientY);
}

// keyboard — detect Enter
function handleKeyDown(
  e: React.KeyboardEvent<HTMLInputElement>
) {
  if (e.key === "Enter") submit();
}

generic components

TypeScript

interface ListProps<T> {
  items:        T[];
  renderItem:   (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
}: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}

// TypeScript knows each 'item' is a User
<List
  items={users}
  keyExtractor={(user) => String(user.id)}
  renderItem={(user) => <span>{user.name}</span>}
/>

Testing React Components

React Testing Library tests components the way a real user would use them — finding buttons, typing in inputs, and checking what appears on screen.

install and setup

bash

# install (Vite projects)
npm install -D vitest @testing-library/react @testing-library/user-event jsdom

basic component test

TypeScript

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import Button from "./Button";

describe("Button", () => {
  it("shows the label", () => {
    render(<Button label="Save" />);
    expect(
      screen.getByRole("button", { name: "Save" })
    ).toBeInTheDocument();
  });

  it("calls onClick when clicked", async () => {
    const handleClick = vi.fn();
    render(<Button label="Save" onClick={handleClick} />);

    await userEvent.click(
      screen.getByRole("button", { name: "Save" })
    );
    expect(handleClick).toHaveBeenCalledOnce();
  });

  it("is disabled when prop is passed", () => {
    render(<Button label="Save" disabled />);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

testing forms and async behaviour

TypeScript

import {
  render,
  screen,
  waitFor
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LoginForm from "./LoginForm";

it("logs in with email and password", async () => {
  render(<LoginForm />);

  await userEvent.type(
    screen.getByPlaceholderText("Email"),
    "[email protected]"
  );

  await userEvent.type(
    screen.getByLabelText("Password"),
    "mypassword123"
  );

  await userEvent.click(
    screen.getByRole("button", { name: "Log in" })
  );

  // wait for async success message
  await waitFor(() => {
    expect(
      screen.getByText("Welcome back!")
    ).toBeInTheDocument();
  });
});
Was this helpful?

No login required to share feedback

More Cheatsheets

Keep your reference handy

Explore more zero-to-hero cheatsheets for the tools you use daily.