2: React Practice Components

Three components built from the Quick Start concepts. Each one is annotated not just for what it does but for what it gets wrong in its naive form — the failure modes you only discover after shipping.


1. ProfileCard

Concepts used: components, JSX, props with defaults, conditional rendering, TypeScript interfaces

A profile card with an availability indicator. The naive implementation uses inline styles throughout and a bare string for the availability status. Here’s why both choices create problems at scale.

The inline style problem. Inline styles in React have three issues: they can’t be overridden by external stylesheets (inline styles have the highest specificity short of !important), they duplicate style logic across renders as new objects, and they don’t support pseudo-selectors or media queries. They’re fine for truly dynamic values — a width calculated from data — but not for static design decisions.

The isAvailable: boolean problem. A boolean status field encodes exactly two states. Real availability has more: available, busy, away, offline. Using a boolean means a breaking type change when requirements expand. A string literal union is more expressive and still fully type-safe:

type AvailabilityStatus = 'available' | 'busy' | 'away' | 'offline'

const STATUS_COLORS: Record<AvailabilityStatus, string> = {
  available: '#22c55e',
  busy: '#ef4444',
  away: '#f59e0b',
  offline: '#6b7280',
}

interface User {
  name: string
  role: string
  avatarUrl: string
  status: AvailabilityStatus
}

const defaultUser: User = {
  name: 'James Han',
  role: 'Software Engineer',
  avatarUrl: '/images/site/profile1.png',
  status: 'available',
}

export default function ProfileCard({ user = defaultUser }: { user?: User }) {
  const statusColor = STATUS_COLORS[user.status]

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
      <img
        src={user.avatarUrl}
        alt={`Profile photo of ${user.name}`}
        loading="lazy"
        width={80}
        height={80}
      />
      <div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
        <h1 style={{ marginBottom: 0 }}>{user.name}</h1>
        <span
          role="img"
          aria-label={`Status: ${user.status}`}
          style={{
            borderRadius: '50%',
            width: 10,
            height: 10,
            marginTop: 35,
            backgroundColor: statusColor,
            flexShrink: 0,
          }}
        />
      </div>
      <h3 style={{ marginTop: 0 }}>{user.role}</h3>
    </div>
  )
}

Three changes from the naive version worth noting:

  1. status: AvailabilityStatus replaces isAvailable: boolean. The STATUS_COLORS record lookup replaces the ternary — adding a new status means adding one entry to the record, not hunting for ternaries across the codebase.

  2. The status dot gets role="img" and aria-label. A colored circle is invisible to screen readers without it. The label reads “Status: available” rather than just “available” so it has context when read in isolation.

  3. <img> gets an explicit alt, loading="lazy", and dimensions. Without dimensions, the browser doesn’t know how much space to reserve and you get layout shift (CLS) as the image loads. Without loading="lazy", images below the fold load immediately and compete with critical resources.

Live demo:

James Han

Software Engineer


2. TodoList

Concepts used: useState, controlled inputs, immutable state updates, .map() with keys, component extraction, keyboard events, functional state updates

An interactive todo list with add/toggle/delete and a split view between active and completed tasks. The interesting engineering is in the state update patterns and keyboard handling.

The naive version and its bugs:

// Bug 1: TodoItem has no TypeScript props
function TodoItem({id, text, done, handleCheck, index}) { ... }

// Bug 2: Date.now() isn't unique at high call frequency
// Two todos added in the same millisecond get the same id
const handleNewTodo = () => {
  setTodos([...todos, { id: Date.now(), text, done: false }])
}

// Bug 3: stale closure — reads `todos` from render closure
// If called twice before re-render (e.g. fast typing + click), second call sees stale state
const handleNewTodo = () => {
  setTodos([...todos, { id: ..., text, done: false }])
}

// Bug 4: no Enter key support — users expect it
// Bug 5: no guard on empty text — submitting empty string adds a blank todo

The corrected version:

import { useState } from 'react'

type TodoId = string  // branded would be even safer, but string is fine here

interface Todo {
  id: TodoId
  text: string
  done: boolean
}

interface TodoItemProps {
  todo: Todo
  index: number
  onToggle: (id: TodoId) => void
  onDelete: (id: TodoId) => void
}

function TodoItem({ todo, index, onToggle, onDelete }: TodoItemProps) {
  return (
    <span style={{ display: 'flex', flexDirection: 'row', gap: 3, alignItems: 'center' }}>
      <input
        type="checkbox"
        id={`todo-${todo.id}`}
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
        style={{ margin: 0 }}
      />
      <label
        htmlFor={`todo-${todo.id}`}
        style={{ textDecoration: todo.done ? 'line-through' : 'none', margin: 4 }}
      >
        {index + 1}. {todo.text}
      </label>
      <button
        onClick={() => onDelete(todo.id)}
        aria-label={`Delete "${todo.text}"`}
        style={{ marginLeft: 'auto' }}
      >
        ×
      </button>
    </span>
  )
}

export default function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [text, setText] = useState('')

  const handleAdd = () => {
    const trimmed = text.trim()
    if (!trimmed) return

    // crypto.randomUUID() is unique by construction, not by timing
    setTodos(prev => [...prev, { id: crypto.randomUUID(), text: trimmed, done: false }])
    setText('')
  }

  const handleToggle = (id: TodoId) => {
    // Functional update: reads latest state, not closure's snapshot
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t))
  }

  const handleDelete = (id: TodoId) => {
    setTodos(prev => prev.filter(t => t.id !== id))
  }

  const active = todos.filter(t => !t.done)
  const completed = todos.filter(t => t.done)

  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      <div style={{ display: 'flex', flexDirection: 'row', gap: 8, marginBottom: 10 }}>
        <input
          placeholder="Todo name"
          value={text}
          onChange={e => setText(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter') handleAdd() }}
          aria-label="New todo text"
        />
        <button onClick={handleAdd}>Add</button>
      </div>
      <div style={{ display: 'flex', flexDirection: 'row', gap: 30 }}>
        <div>
          <h3 style={{ margin: 0 }}>Unfinished ({active.length})</h3>
          {active.map((todo, i) => (
            <TodoItem key={todo.id} todo={todo} index={i} onToggle={handleToggle} onDelete={handleDelete} />
          ))}
        </div>
        <div>
          <h3 style={{ margin: 0 }}>Completed ({completed.length})</h3>
          {completed.map((todo, i) => (
            <TodoItem key={todo.id} todo={todo} index={i} onToggle={handleToggle} onDelete={handleDelete} />
          ))}
        </div>
      </div>
    </div>
  )
}

What changed and why:

crypto.randomUUID() replaces Date.now(). The problem with timestamp IDs is that they’re only unique if calls are spaced at least 1ms apart. In tests or fast interaction sequences, duplicates occur. crypto.randomUUID() is UUID v4 — 122 bits of entropy, unique by construction, available natively in all modern browsers and Node 15+.

Functional updates (prev => ...) replace closure reads for all state setters. The naive setTodos([...todos, ...]) captures todos from the current render’s closure. If handleAdd is called twice before React re-renders (rare but possible with programmatic triggers), the second call reads the stale todos and overwrites the first update. The functional form always receives the latest committed state, not the closure snapshot.

TodoItem now accepts a structured todo object prop rather than spreading all fields individually. The spread ({...todo}) was coupling TodoItem’s prop interface to Todo’s field names — any field rename in Todo requires finding and updating every spread site. Passing the object explicitly makes the dependency visible.

The checkbox gets an id paired with a <label htmlFor>. Without the <label>, clicking the text next to a checkbox doesn’t toggle it. This is standard accessibility behavior users expect — interactive area is the label text, not just the 20×20px checkbox square.

Live demo:

Unfinished Tasks

Completed Tasks


3. ThemeSwitcher

Concepts used: lifting state up, string literal unions, CSS custom properties for theming, controlled stateless components

A parent component that owns theme state and passes it down. The naive version uses string for the theme type and inline style ternaries in each child. Both choices scale poorly.

The theme: string problem. Typing state as string means TypeScript can’t catch "Dark" vs "dark" typos, can’t exhaustively check conditions, and can’t autocomplete prop values. If ThemeToggle and ThemedBox each contain theme === 'Light', changing the theme token from 'Light' to 'light' silently breaks the UI — no type error, no runtime error, just wrong behavior.

The inline style ternary problem. Each child component duplicates the mapping from theme to colors:

// In ThemedBox
backgroundColor: theme === 'Light' ? 'white' : 'black'
color: theme === 'Light' ? 'black' : 'white'

// If you add a third theme, you hunt for every ternary

CSS custom properties solve this cleanly. The component sets variables at the root; the stylesheet consumes them. Adding a new theme means adding one entry to a lookup table, not searching for ternaries:

import { useState } from 'react'

type Theme = 'light' | 'dark'

const THEME_VARS: Record<Theme, React.CSSProperties> = {
  light: {
    '--bg': 'white',
    '--fg': 'black',
    '--border': '#e5e7eb',
  } as React.CSSProperties,
  dark: {
    '--bg': '#1a1a1a',
    '--fg': '#f5f5f5',
    '--border': '#374151',
  } as React.CSSProperties,
}

interface ThemeToggleProps {
  theme: Theme
  onToggle: () => void
}

function ThemeToggle({ theme, onToggle }: ThemeToggleProps) {
  const next = theme === 'light' ? 'dark' : 'light'
  return (
    <button
      onClick={onToggle}
      aria-label={`Switch to ${next} mode`}
      style={{ alignSelf: 'center' }}
    >
      Switch to {next} mode
    </button>
  )
}

function ThemedBox({ theme }: { theme: Theme }) {
  return (
    <div
      style={{
        ...THEME_VARS[theme],
        alignSelf: 'center',
        padding: '1rem',
        backgroundColor: 'var(--bg)',
        color: 'var(--fg)',
        border: '1px solid var(--border)',
        borderRadius: 4,
      }}
    >
      This box should change theme when you click the button above.
    </div>
  )
}

export default function ThemeSwitcher() {
  const [theme, setTheme] = useState<Theme>('light')

  const handleToggle = () => setTheme(prev => prev === 'light' ? 'dark' : 'light')

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-start' }}>
      <ThemeToggle theme={theme} onToggle={handleToggle} />
      <ThemedBox theme={theme} />
    </div>
  )
}

What changed and why:

Theme = 'light' | 'dark' replaces string. TypeScript now catches all invalid theme values at compile time. The exhaustive lookup table THEME_VARS replaces scattered ternaries — adding 'system' as a theme value means TypeScript will error on THEME_VARS until you add the entry, forcing completeness.

ThemeToggle derives next from theme instead of passing theme directly into the button text. In the naive version, the button reads “Switch to Light Mode” when the theme is already light — the label and the action are misaligned. Computing next from theme ensures the button always describes what it will do, not what it currently is.

handleToggle uses the functional update form (prev => ...) for the same reason as handleAdd in TodoList: the next state depends on the previous state, so you should always read from prev, not from the closure.

The THEME_VARS[theme] pattern inlines the variables as CSS custom properties via the style attribute. In a real application you’d set these on document.documentElement or a scoped root element, and the entire stylesheet would respond — components further down the tree don’t need to be aware of the theme at all. The local version here is illustrative; the production version would hoist the variable application to a higher level.

Live demo:

This box should change theme when you click the button above!