Jump to section
React Cheatsheet
A practical React guide covering components, hooks, state, context, and server features.
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 directlycreate, 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 memouseTransition — 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 jsdombasic 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();
});
});No login required to share feedback
More Cheatsheets
Keep your reference handy
Explore more zero-to-hero cheatsheets for the tools you use daily.