skip to content

React Basics — Function components, hooks, and JSX

Foundational React patterns — function components, JSX, props, hooks (useState, useEffect, useRef, useContext), list rendering, and form handling — with TypeScript throughout.

15 min read 40 snippets deep dive

React Basics — Function components, hooks, and JSX#

What it is#

React is a JavaScript library for building user interfaces by composing reusable components. It is developed by Meta and a large open-source community, written in TypeScript, and runs anywhere JavaScript runs — browser, server (via frameworks like Next.js or Remix), and native (via React Native). Modern React is “function components plus hooks”: no classes, no lifecycle methods, just plain functions that return JSX and call hooks to read state and side-effects. Alternatives in the same niche include Vue, Svelte, and Solid — but React’s ecosystem and hiring market remain the broadest.

Install#

There is no react CLI — you scaffold a React project with a build tool. Vite is the most common modern starter; Next.js is the dominant meta-framework. For an existing project, install react and react-dom together.

# Brand new Vite + React + TypeScript project
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

# Add React to an existing project
npm install react react-dom
npm install -D @types/react @types/react-dom

Output: (none — exits 0 on success)

Hello, function component#

A function component is a JavaScript function that returns a piece of UI as JSX. It receives data through props (its single argument) and renders whatever JSX the function evaluates to. The component name must start with a capital letter — that is how the JSX compiler tells <Hello /> (a component) apart from <div> (a DOM tag).

// src/components/Hello.tsx
type HelloProps = { name: string };

export function Hello({ name }: HelloProps) {
  return <h1>Hello, {name}!</h1>;
}

// src/main.tsx — mount it
import { createRoot } from "react-dom/client";
import { Hello } from "./components/Hello";

const root = createRoot(document.getElementById("root")!);
root.render(<Hello name="Alice Dev" />);

Output: (rendered to the DOM)

<h1>Hello, Alice Dev!</h1>

JSX#

JSX is an HTML-like syntax that compiles to React.createElement(...) calls. Anything inside {} is a JavaScript expression that is evaluated and inserted. Attributes follow JS naming (className, not class; htmlFor, not for; onClick, not onclick). A component must return a single root node — wrap multiple siblings in a <>...</> fragment if you do not want an extra DOM element.

function Greeting({ user }: { user: { name: string; admin: boolean } }) {
  const now = new Date().toLocaleTimeString();

  return (
    <>
      <h2 className="title">Hello, {user.name}</h2>
      <p>Time: {now}</p>
      {user.admin && <span className="badge">admin</span>}
      <ul style={{ listStyle: "square" }}>
        {[1, 2, 3].map((n) => (
          <li key={n}>Item {n}</li>
        ))}
      </ul>
    </>
  );
}

Output: (rendered HTML)

<h2 class="title">Hello, Alice Dev</h2>
<p>Time: 09:42:13</p>
<span class="badge">admin</span>
<ul style="list-style: square;">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>

Props and children#

Props are the read-only inputs to a component, passed as JSX attributes. The special children prop receives whatever JSX is nested between the component’s opening and closing tags — this is how layout/wrapper components work. Type props with a TS interface or type alias for safety and editor autocomplete.

import type { ReactNode } from "react";

type CardProps = {
  title: string;
  variant?: "default" | "warning" | "danger";
  children: ReactNode;
};

export function Card({ title, variant = "default", children }: CardProps) {
  return (
    <section className={`card card-${variant}`}>
      <header>{title}</header>
      <div className="card-body">{children}</div>
    </section>
  );
}

// Usage
function App() {
  return (
    <Card title="Account" variant="warning">
      <p>Your trial ends in 3 days.</p>
      <button>Upgrade</button>
    </Card>
  );
}

Output: (rendered HTML)

<section class="card card-warning">
  <header>Account</header>
  <div class="card-body">
    <p>Your trial ends in 3 days.</p>
    <button>Upgrade</button>
  </div>
</section>

Common prop types#

TypePurpose
string, number, booleanPrimitive props
ReactNodeAnything renderable (string, number, element, array, null)
ReactElementA single JSX element
(e: React.MouseEvent) => voidClick handlers
(e: React.ChangeEvent<HTMLInputElement>) => voidInput change handlers
React.CSSPropertiesInline style objects
React.ComponentProps<"button">All native props of a tag

useState#

useState adds local mutable state to a function component. It returns a [value, setter] tuple: read value to render, call setter(next) to schedule a re-render with the new value. The setter is batched and asynchronous — calling it does not change the current value variable inside the same function call. To compute the next value from the previous, pass a function: setCount(c => c + 1).

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount((c) => c + 1)}>+1 (functional)</button>
      <button onClick={() => setCount(0)}>reset</button>
    </div>
  );
}

Output: (after clicking +1 three times)

Count: 3

Lazy initial state#

If the initial value is expensive to compute, pass a function instead of a value. React will only call it on the first render.

const [users, setUsers] = useState<User[]>(() => {
  const cached = localStorage.getItem("users");
  return cached ? JSON.parse(cached) : [];
});

Output: (none — exits 0 on success)

State with objects and arrays#

State must be treated as immutable — never mutate it, always create a new object/array. React compares state with Object.is, so a mutation goes unnoticed and no re-render happens.

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

// WRONG — mutation, no re-render
// user.age = 31;

// RIGHT — new object
setUser({ ...user, age: 31 });
setUser((prev) => ({ ...prev, age: prev.age + 1 }));

const [todos, setTodos] = useState<string[]>([]);
setTodos((prev) => [...prev, "buy milk"]);          // append
setTodos((prev) => prev.filter((t) => t !== "buy milk")); // remove

Output: (none — exits 0 on success)

useEffect#

useEffect runs side effects (network requests, subscriptions, DOM mutations, timers) after the component renders. The second argument is a dependency array: the effect re-runs only when one of those values changes. Return a cleanup function from the effect to undo subscriptions/timers when the dependencies change or the component unmounts.

import { useState, useEffect } from "react";

export function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);     // cleanup
  }, []);                                // empty deps → run once on mount

  return <p>Current time: {time.toLocaleTimeString()}</p>;
}

Output: (rendered, updates every second)

Current time: 09:42:13
Current time: 09:42:14
Current time: 09:42:15

Dependency array rules#

PatternWhen effect runs
useEffect(fn)After every render (rare — usually a bug)
useEffect(fn, [])Once after mount; cleanup on unmount
useEffect(fn, [a, b])After mount + whenever a or b changes

Fetching data#

import { useState, useEffect } from "react";

type User = { id: number; name: string };

export function UserCard({ id }: { id: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();

    fetch(`/api/users/${id}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(setUser)
      .catch((e) => {
        if (e.name !== "AbortError") setError(String(e));
      });

    return () => ctrl.abort();        // cancel on unmount / id change
  }, [id]);

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

Output: (rendered)

Loading...
Alice Dev

useRef#

useRef returns a mutable container { current: T } that persists across renders without triggering a re-render when you change .current. Two main uses: holding a DOM node reference (pass to a JSX ref attribute) and storing a mutable value that should not cause re-renders (timer IDs, previous values, scroll positions).

import { useRef, useEffect } from "react";

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

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

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

export function StopwatchToggle() {
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  const start = () => {
    if (intervalRef.current) return;
    intervalRef.current = setInterval(() => console.log("tick"), 1000);
  };

  const stop = () => {
    if (!intervalRef.current) return;
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };

  return (
    <>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  );
}

Output: (console after starting and waiting 3 seconds)

tick
tick
tick

useContext + createContext#

Context lets you share a value with every component beneath a provider without passing props at every level. Create a context with createContext(default), wrap a tree with <MyContext.Provider value={...}>, and read it with useContext(MyContext) anywhere inside. Context is best for low-frequency, app-wide values (theme, current user, locale) — not for state that changes on every keystroke (use Zustand/Redux/Jotai for that, or co-locate state higher up).

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

type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
  return ctx;
}

// Usage anywhere in the tree
function ThemeToggleButton() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>Theme: {theme}</button>;
}

Output: (after one click)

Theme: dark

List rendering and key#

When you render an array of items with .map(), React needs a stable, unique key prop on each element so it can match up items across renders and reorder/insert/delete efficiently. Use the item’s natural ID — never the array index unless the list is truly static (no inserts, deletes, or reorders), because index keys cause state to leak between items when the list changes.

type Todo = { id: string; text: string; done: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} style={{ textDecoration: todo.done ? "line-through" : "none" }}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

Output: (rendered)

<ul>
  <li style="text-decoration: none;">buy milk</li>
  <li style="text-decoration: line-through;">walk the dog</li>
</ul>

Conditional rendering#

JSX is just JavaScript, so any expression works for branching: ternaries, &&, early returns, or helper functions.

function Status({ user }: { user: { loggedIn: boolean; admin: boolean } | null }) {
  // Early return
  if (!user) return <p>Not signed in.</p>;

  return (
    <>
      {/* Ternary */}
      {user.loggedIn ? <p>Welcome!</p> : <p>Please sign in.</p>}

      {/* && — render-or-nothing */}
      {user.admin && <button>Admin panel</button>}

      {/* Nullish coalescing for empty state */}
      <p>{user.admin ? "admin" : "user"}</p>
    </>
  );
}

Output: (rendered for an admin user)

<p>Welcome!</p>
<button>Admin panel</button>
<p>admin</p>

Pitfall: 0 && ...#

{count && <X />} renders the number 0 when count is 0 — JSX renders numbers as text. Use count > 0 && <X /> or !!count && <X /> instead.

{count > 0 && <p>{count} items</p>}

Output: (none — exits 0 on success)

Event handlers and synthetic events#

React wraps native DOM events in a cross-browser SyntheticEvent object. Handlers are passed by JSX attribute (onClick, onChange, onSubmit, …). The synthetic event has the same shape as the native one (.target, .preventDefault(), .stopPropagation()) plus React-specific helpers.

import type { MouseEvent, FormEvent } from "react";

function Button() {
  function handleClick(e: MouseEvent<HTMLButtonElement>) {
    e.preventDefault();
    console.log("clicked", e.currentTarget.dataset.id);
  }

  return <button data-id="42" onClick={handleClick}>Click me</button>;
}

function LoginForm() {
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log({
      email: data.get("email"),
      password: data.get("password"),
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Sign in</button>
    </form>
  );
}

Output: (console after submission)

{ email: 'alice@example.com', password: 'hunter2' }

Common event types#

EventTS type
ClickReact.MouseEvent<HTMLButtonElement>
Input changeReact.ChangeEvent<HTMLInputElement>
Form submitReact.FormEvent<HTMLFormElement>
KeyboardReact.KeyboardEvent<HTMLInputElement>
FocusReact.FocusEvent<HTMLInputElement>

Controlled vs uncontrolled inputs#

A controlled input binds its value to state and updates that state in onChange: React is the single source of truth. An uncontrolled input lets the DOM keep the value internally and you read it with a ref or FormData on submit. Prefer controlled inputs when you need live validation, conditional rendering based on the value, or multiple synchronized fields; prefer uncontrolled for plain forms where you only care about the final value at submit time.

import { useState, useRef } from "react";

// Controlled — value bound to state
export function ControlledEmail() {
  const [email, setEmail] = useState("");
  const valid = email.includes("@");

  return (
    <>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      {!valid && email && <p>Invalid email</p>}
      <button disabled={!valid}>Submit</button>
    </>
  );
}

// Uncontrolled — read via ref
export function UncontrolledEmail() {
  const ref = useRef<HTMLInputElement>(null);

  return (
    <>
      <input ref={ref} type="email" defaultValue="" />
      <button onClick={() => console.log(ref.current?.value)}>
        Log value
      </button>
    </>
  );
}

Output: (console after typing then clicking the uncontrolled button)

alice@example.com

A typed form with Zod validation#

Combine controlled inputs with Zod for end-to-end type safety: define the schema, derive the type, validate on submit, and render errors.

import { useState } from "react";
import { z } from "zod";

const SignupSchema = z.object({
  email: z.string().email("must be a valid email"),
  password: z.string().min(8, "at least 8 characters"),
  age: z.coerce.number().int().min(13, "must be 13 or older"),
});

type Signup = z.infer<typeof SignupSchema>;
type Errors = Partial<Record<keyof Signup, string>>;

export function SignupForm() {
  const [form, setForm] = useState({ email: "", password: "", age: "" });
  const [errors, setErrors] = useState<Errors>({});

  function update<K extends keyof typeof form>(key: K, value: string) {
    setForm((prev) => ({ ...prev, [key]: value }));
  }

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const result = SignupSchema.safeParse(form);
    if (!result.success) {
      const fieldErrors: Errors = {};
      for (const issue of result.error.issues) {
        fieldErrors[issue.path[0] as keyof Signup] = issue.message;
      }
      setErrors(fieldErrors);
      return;
    }
    setErrors({});
    console.log("submit", result.data);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input
          value={form.email}
          onChange={(e) => update("email", e.target.value)}
        />
        {errors.email && <small className="err">{errors.email}</small>}
      </label>
      <label>
        Password
        <input
          type="password"
          value={form.password}
          onChange={(e) => update("password", e.target.value)}
        />
        {errors.password && <small className="err">{errors.password}</small>}
      </label>
      <label>
        Age
        <input
          value={form.age}
          onChange={(e) => update("age", e.target.value)}
        />
        {errors.age && <small className="err">{errors.age}</small>}
      </label>
      <button type="submit">Sign up</button>
    </form>
  );
}

Output: (console on a valid submission)

submit { email: 'alice@example.com', password: 'hunter2!!', age: 30 }

The React Compiler (overview)#

The React Compiler (React 19+) is an opt-in build-time tool that automatically memoizes components and hooks based on dataflow analysis — eliminating most hand-written useMemo / useCallback / memo boilerplate. Add the babel-plugin-react-compiler (or its SWC equivalent in your bundler config); your existing source code is unchanged, and the compiler inserts memoization where it is provably safe. As long as your components follow the Rules of React (no mutation of props/state, no side effects in render), you get free performance with no code changes.

# Babel toolchain
npm install -D babel-plugin-react-compiler

# Or via SWC (Vite)
npm install -D @swc/plugin-react-compiler

Output: (none — exits 0 on success)

// vite.config.ts — enable in Vite + SWC
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

export default defineConfig({
  plugins: [
    react({
      plugins: [["@swc/plugin-react-compiler", {}]],
    }),
  ],
});

Common pitfalls#

  1. Mutating stateusers.push(x) instead of setUsers([...users, x]). React compares with Object.is; a mutated reference looks unchanged, so no re-render.
  2. Stale closures in effects — referencing state inside a setInterval/setTimeout without putting it in the deps array. Use a functional updater (setX(prev => …)) or add the value to deps.
  3. Missing key or using array index — index keys break when the list reorders. Always use a stable ID.
  4. Component name starts lowercase<myComponent /> is treated as a DOM tag and silently rendered as <mycomponent>. Always capitalize.
  5. useState initial value runs every renderuseState(expensiveCompute()) calls expensiveCompute() on every render (the result is discarded). Use useState(() => expensiveCompute()).
  6. Effect running on every render — forgetting the deps array. useEffect(fn) (no second arg) runs after every render, causing infinite fetches.
  7. Setting state during rendersetX() in the function body without a guard creates an infinite re-render loop. Only call setters from event handlers, effects, or initialization.
  8. {value && <X />} when value is 0 — renders the literal 0. Use value > 0 && <X /> or !!value && <X />.
  9. Forgetting cleanup in effects — subscribers, timers, and AbortControllers must be cleaned up in the returned function or you leak memory / get duplicate handlers in dev (Strict Mode mounts twice).
  10. Conditional hooks — never call a hook inside if/for/early return. React relies on call order; conditional hooks throw “Rendered fewer/more hooks than during the previous render”.

Real-world recipes#

Debounced search input#

A controlled text input that fires an API request 300 ms after the user stops typing — the canonical debounce pattern, expressed as an effect.

import { useState, useEffect } from "react";

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

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    const ctrl = new AbortController();
    const id = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: ctrl.signal,
      });
      const json: string[] = await res.json();
      setResults(json);
    }, 300);
    return () => {
      clearTimeout(id);
      ctrl.abort();
    };
  }, [query]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="search…" />
      <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
    </>
  );
}

Output: (console while typing “ali”)

GET /api/search?q=ali  -> ["alice", "alicia", "alistair"]

Persistent state with localStorage#

A custom hook that mirrors a piece of state to localStorage so it survives reloads — composing useState and useEffect into a reusable building block.

import { useState, useEffect } from "react";

function usePersistentState<T>(key: string, initial: T): [T, (v: T) => void] {
  const [value, setValue] = useState<T>(() => {
    const cached = localStorage.getItem(key);
    return cached ? (JSON.parse(cached) as T) : initial;
  });

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

  return [value, setValue];
}

// Usage
function App() {
  const [theme, setTheme] = usePersistentState<"light" | "dark">("theme", "light");
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      Theme: {theme}
    </button>
  );
}

Output: (after toggling and reloading the page)

Theme: dark

A reusable modal: rendered into document.body via createPortal, autofocuses its first input on mount, and traps Escape to close. Combines useRef, useEffect, and props.

import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";

type ModalProps = { open: boolean; onClose: () => void; children: ReactNode };

export function Modal({ open, onClose, children }: ModalProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    ref.current?.querySelector<HTMLElement>("input,button,select,textarea")?.focus();
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  if (!open) return null;
  return createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div ref={ref} className="modal" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body,
  );
}

Output: (rendered into document.body when open)

<div class="modal-backdrop">
  <div class="modal">…</div>
</div>

Pairing with Vite or Bun#

The fastest way to start: Vite (most popular) or Bun (single-binary, slightly faster install).

# Vite + React + TypeScript + SWC compiler
npm create vite@latest my-app -- --template react-swc-ts

# Bun + React (uses Bun's native bundler/server)
bun create react my-app

Output:

✔ Project name: my-app
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC

Scaffolding project in /home/alice/my-app...

Done. Now run:
  cd my-app
  npm install
  npm run dev