1: React Quick Start

React is a library for building user interfaces out of composable components. This page covers the core concepts you’ll use daily, with the underlying mechanics that explain why the rules are the rules.


1. Creating and Nesting Components

A React component is a function that returns JSX. Understanding what JSX actually compiles to makes the rules stop feeling arbitrary.

What JSX is. JSX is syntactic sugar over React.createElement calls. This:

function Greeting() {
  return <h2>Hello, React!</h2>
}

compiles to:

function Greeting() {
  return React.createElement('h2', null, 'Hello, React!')
}

The new JSX transform (React 17+, enabled by default in Vite and Next.js) inlines the import automatically — you no longer need import React from 'react' at the top of every file. If you’re still writing that import in 2024, your bundler config is outdated.

Why component names must be capitalized. The JSX transform uses the casing to decide what to emit. <div> compiles to createElement('div', ...) — a string, meaning a native DOM element. <Greeting> compiles to createElement(Greeting, ...) — a reference to your function. A lowercase custom component would compile to a string and React would try to render it as an unknown HTML tag.

interface GreetingProps {
  name: string
}

function Greeting({ name }: GreetingProps) {
  return <h2>Hello, {name}!</h2>
}

export default function MyApp() {
  return (
    <div>
      <h1>My Page</h1>
      <Greeting name="React" />
    </div>
  )
}

The interface for props isn’t optional ceremony — TypeScript will catch mismatched prop types, missing required props, and typos in prop names at compile time, not at runtime.


2. Writing Markup with JSX

The single root element rule. A component can only return one root element because, after compilation, it’s a single createElement call returning one value. Fragments (<>...</>) are the correct solution when you don’t want an extra DOM node — not a wrapping <div>.

The distinction matters for accessibility and CSS. Wrapping list items in a spurious div breaks CSS selectors like ul > li. Wrapping table rows in a div produces invalid HTML. Fragments produce no DOM node at all:

function TableRows() {
  return (
    <>
      <tr><td>Row 1</td></tr>
      <tr><td>Row 2</td></tr>
    </>
  )
  // Valid HTML — no wrapper div that would break <tbody> structure
}

JSX expressions. Curly braces embed any JavaScript expression — not statements. if is a statement, so it can’t appear directly in JSX. The ternary and && are expressions, so they can.

The && trap. The logical && short-circuits and returns the left operand if it’s falsy. When the left side is 0 or NaN, React renders those values as text:

// Bug — renders "0" when count is 0
{count && <Badge count={count} />}

// Correct — Boolean coercion guarantees true/false, never a rendered value
{count > 0 && <Badge count={count} />}

// Also correct
{!!count && <Badge count={count} />}

This is one of the most common bugs in React codebases. The fix is to make the condition explicitly boolean.


3. Adding Styles

Why className exists. class is a reserved keyword in JavaScript. Since JSX compiles to JS, the attribute had to be renamed. htmlFor on <label> exists for the same reason (for is a reserved word).

The global styles problem. Plain .css files work but don’t scale. Every class name lives in a global namespace, so card in Card.css and card in Sidebar.css conflict. Three solutions in order of rigor:

CSS Modules — Vite supports them natively. Class names are locally scoped by default, compiled to unique identifiers:

// Card.module.css
.card { padding: 1rem; }
.title { font-size: 1.25rem; }

// Card.tsx
import styles from './Card.module.css'

function Card() {
  return (
    <div className={styles.card}>
      <h3 className={styles.title}>React Basics</h3>
    </div>
  )
}
// Compiles to: <div class="Card_card__xK2p9">

CSS custom properties as design tokens. For dynamic styling (theme switching, user preferences), set CSS variables from JS rather than toggling classes or using inline styles:

// Set once at the root level
document.documentElement.style.setProperty('--color-accent', '#3b82f6')

// Every element using var(--color-accent) updates automatically

This is how component libraries like Radix and shadcn/ui handle theming. The JS layer manages values; the CSS layer manages how those values are applied.

clsx for conditional classes. The common pattern for combining class names conditionally:

import clsx from 'clsx'

function Button({ variant, disabled }: { variant: 'primary' | 'ghost', disabled?: boolean }) {
  return (
    <button
      className={clsx(
        styles.button,
        variant === 'primary' && styles.primary,
        variant === 'ghost' && styles.ghost,
        disabled && styles.disabled,
      )}
    />
  )
}

String concatenation with conditionals is fragile and hard to read. clsx filters falsy values and joins the rest.


4. Displaying Data

Typing props. Every prop interface should be explicit. TypeScript will narrow union types through props:

interface UserProfile {
  name: string
  imageUrl: string
  imageSize?: number  // optional — has a default
}

function Profile({ name, imageUrl, imageSize = 120 }: UserProfile) {
  return (
    <>
      <h1>{name}</h1>
      <img
        className="avatar"
        src={imageUrl}
        alt={`photo of ${name}`}
        style={{ width: imageSize, height: imageSize }}
        loading="lazy"
      />
    </>
  )
}

The loading="lazy" attribute is a native HTML attribute that defers off-screen image loading. JSX passes it through to the DOM unchanged — you don’t need JavaScript for this.

The children prop. Components that wrap other content use the children prop. TypeScript’s type for it is React.ReactNode:

interface CardProps {
  title: string
  children: React.ReactNode
}

function Card({ title, children }: CardProps) {
  return (
    <div className={styles.card}>
      <h3>{title}</h3>
      {children}
    </div>
  )
}

// Usage
<Card title="Hello"><p>any content here</p></Card>

5. Conditional Rendering

Ternary vs && vs early return. Each has a different appropriate use:

// Ternary — two branches
function AuthStatus({ isLoggedIn }: { isLoggedIn: boolean }) {
  return (
    <div>
      {isLoggedIn ? <WelcomeMessage /> : <LoginButton />}
    </div>
  )
}

// && — one branch, the other renders nothing
function Notification({ hasUnread }: { hasUnread: boolean }) {
  return (
    <nav>
      <Logo />
      {hasUnread && <UnreadBadge />}
    </nav>
  )
}

// Early return — complex conditions belong outside JSX
function UserCard({ user }: { user: User | null }) {
  if (!user) return <Skeleton />
  if (user.isBanned) return <BannedMessage />
  return <Profile user={user} />
}

The early return pattern keeps JSX clean. Deeply nested ternaries inside JSX are hard to read and harder to maintain.

Discriminated unions for component state. Rather than multiple boolean flags (isLoading, isError, isEmpty), a discriminated union makes the state machine explicit and exhaustive:

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T }

function DataView({ state }: { state: FetchState<User[]> }) {
  switch (state.status) {
    case 'idle':     return <Prompt />
    case 'loading':  return <Spinner />
    case 'error':    return <ErrorMessage error={state.error} />
    case 'success':  return <UserList users={state.data} />
  }
}

TypeScript enforces that all cases are handled. With boolean flags, you can forget to handle isLoading && isError simultaneously and the bug won’t surface until runtime.


6. Rendering Lists — Keys and Reconciliation

Why keys exist. When React re-renders a list, it needs to match old virtual DOM nodes to new ones to compute the minimal set of DOM mutations. Keys are the identity signal. Without them, React falls back to positional matching.

Positional matching breaks in two specific ways:

// If you insert at the beginning without keys:
// Before: [A, B, C]
// After:  [X, A, B, C]
// React sees: position 0 changed A→X, position 1 changed B→A, ...
// It updates all four nodes instead of inserting one.

// With keys, React sees: X is new (insert), A/B/C are unchanged (skip).

This matters for performance (unnecessary DOM writes) and for correctness: uncontrolled inputs (like <input> without value) retain their DOM state when React skips the update, so the wrong input ends up with the wrong value if keys are wrong.

Why index keys are wrong. Using the array index as key breaks whenever the list can be reordered, filtered, or have items prepended:

// Wrong — key is position, not identity
{items.map((item, i) => <Row key={i} item={item} />)}

// Correct — key is stable identity
{items.map(item => <Row key={item.id} item={item} />)}

If there’s no natural ID, generate one when the data is created (a crypto.randomUUID() at insertion time), not at render time. Keys generated at render time change on every render, defeating the purpose.

interface Language {
  id: number
  name: string
  year: number
}

const languages: Language[] = [
  { id: 1, name: 'JavaScript', year: 1995 },
  { id: 2, name: 'TypeScript', year: 2012 },
  { id: 3, name: 'Python', year: 1991 },
]

function LanguageList() {
  return (
    <ul>
      {languages.map(lang => (
        <li key={lang.id}>
          {lang.name} ({lang.year})
        </li>
      ))}
    </ul>
  )
}

7. State — useState and Batching

useState fundamentals. State in React is a value that, when changed, schedules a re-render. The setter doesn’t mutate the current value — it schedules the next render with a new value.

import { useState } from 'react'

function Toggle() {
  const [isOn, setIsOn] = useState(false)

  return (
    <>
      <button onClick={() => setIsOn(prev => !prev)}>
        {isOn ? 'ON' : 'OFF'}
      </button>
    </>
  )
}

Note prev => !prev — the functional update form. When the new state depends on the old state, always use the functional form. The version setIsOn(!isOn) captures isOn from the current render’s closure. If the setter is called multiple times before re-render (in an async handler or inside a batch), it reads stale state.

React 18 automatic batching. Before React 18, state updates inside setTimeout, Promise callbacks, and native event handlers were not batched — each setState call triggered a separate re-render. React 18 batches all updates regardless of where they occur:

// React 18 — both updates are batched into one re-render
setTimeout(() => {
  setCount(c => c + 1)  // no re-render yet
  setFlag(f => !f)      // triggers one re-render for both
}, 1000)

If you need to force a synchronous render (rare), flushSync from react-dom opts out of batching.

The stale closure problem. This is the most common useState bug:

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

  function handleDelayedIncrement() {
    setTimeout(() => {
      // Bug: 'count' is captured from the render when this was created.
      // If the user clicks multiple times, all timeouts read the same stale count.
      setCount(count + 1)
    }, 1000)
  }

  // Fix: functional update reads the latest state, not the closure's count
  function handleDelayedIncrementFixed() {
    setTimeout(() => {
      setCount(prev => prev + 1)
    }, 1000)
  }
}

The functional update form is the correct default whenever the new state depends on the previous state.


8. Sharing State — Lifting Up and When Not To

The pattern. When two components need to share state, move it to their closest common ancestor. The ancestor owns the state; children receive it as props.

import { useState } from 'react'

interface CounterProps {
  count: number
  onIncrement: () => void
}

function Counter({ count, onIncrement }: CounterProps) {
  return (
    <button onClick={onIncrement}>
      Count: {count}
    </button>
  )
}

function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <h1>Shared Counters</h1>
      <Counter count={count} onIncrement={() => setCount(c => c + 1)} />
      <Counter count={count} onIncrement={() => setCount(c => c + 1)} />
    </div>
  )
}

When lifting state becomes a problem. Lifting state means every intermediate component between the owner and the consumer has to accept and forward props it doesn’t use — prop drilling. Once you’re passing props through three or more levels, the signal-to-noise ratio in those component signatures degrades.

The alternatives, in order of reach:

  • URL state — for anything the user might want to share or bookmark (filters, selected IDs, page numbers). ?tab=settings is free state management that survives page refresh.
  • React Context — for low-frequency global values (current user, theme, locale). Not designed for high-frequency updates; every consumer re-renders when the context value changes.
  • Zustand / Jotai — for shared state that updates frequently or needs to be read in many disconnected parts of the tree. Zustand’s selector-based subscriptions prevent unnecessary re-renders that Context would cause.

The decision tree: if the state belongs in the URL, put it there. If it’s truly global and rarely changes, Context works. If it’s high-frequency or you need selective subscriptions, reach for a state library.