Episode 2 — React Frontend Architecture NextJS / 2.3 — State and Rerendering Logic

2.3.f — Practical Build

In one sentence: Five hands-on projects that solidify your understanding of state, useState, re-rendering, batching, and derived state through building real interactive components.

Navigation: ← 2.3.e — Derived State · Next → 2.3 Exercise Questions


Table of Contents

  1. Project 1: Interactive Counter
  2. Project 2: Toggle Switch UI
  3. Project 3: Temperature Converter
  4. Project 4: Character Counter Textarea
  5. Project 5: Traffic Light Simulator
  6. Key Takeaways
  7. Explain-It Challenge

Project 1: Interactive Counter

Requirements

Build a counter component with these features:

  • Display the current count
  • Increment and decrement buttons
  • Reset button
  • Configurable step size (increment/decrement by 1, 5, 10, or custom)
  • Count cannot go below a minimum (default 0)
  • Count cannot go above a maximum (default 100)
  • Visual feedback: count turns red when at min, green when at max
  • History of the last 10 count values

Planning: Identifying State

What data changes over time?

DataChanges?Derived?State?
Current countYesNoYes
Step sizeYes (user selects)NoYes
Count historyYesNoYes
Is at minimumYesYes (count === min)No (derived)
Is at maximumYesYes (count === max)No (derived)
Can incrementYesYes (count + step <= max)No (derived)
Can decrementYesYes (count - step >= min)No (derived)
Display colorYesYes (depends on count)No (derived)

State: count, step, history Derived: everything else

Implementation

import { useState } from "react";

function InteractiveCounter({ min = 0, max = 100 }) {
  const [count, setCount] = useState(min);
  const [step, setStep] = useState(1);
  const [history, setHistory] = useState([min]);

  // Derived values
  const isAtMin = count <= min;
  const isAtMax = count >= max;
  const canIncrement = count + step <= max;
  const canDecrement = count - step >= min;
  const percentage = ((count - min) / (max - min)) * 100;

  const displayColor = isAtMin
    ? "#dc2626"   // red
    : isAtMax
    ? "#16a34a"   // green
    : "#1d4ed8";  // blue (default)

  function updateCount(newCount) {
    const clamped = Math.max(min, Math.min(max, newCount));
    setCount(clamped);
    setHistory(prev => {
      const updated = [...prev, clamped];
      return updated.slice(-10); // Keep last 10 values
    });
  }

  function increment() {
    updateCount(count + step);
  }

  function decrement() {
    updateCount(count - step);
  }

  function reset() {
    updateCount(min);
  }

  // Step presets
  const stepOptions = [1, 5, 10, 25];

  return (
    <div style={styles.container}>
      <h2 style={styles.heading}>Interactive Counter</h2>

      {/* Count Display */}
      <div style={styles.countDisplay}>
        <span style={{ ...styles.count, color: displayColor }}>
          {count}
        </span>
        <div style={styles.range}>
          Range: {min} - {max}
        </div>
      </div>

      {/* Progress Bar */}
      <div style={styles.progressBar}>
        <div
          style={{
            ...styles.progressFill,
            width: `${percentage}%`,
            backgroundColor: displayColor,
          }}
        />
      </div>

      {/* Controls */}
      <div style={styles.controls}>
        <button
          onClick={decrement}
          disabled={!canDecrement}
          style={styles.button}
        >
          - {step}
        </button>
        <button
          onClick={reset}
          style={{ ...styles.button, ...styles.resetButton }}
        >
          Reset
        </button>
        <button
          onClick={increment}
          disabled={!canIncrement}
          style={styles.button}
        >
          + {step}
        </button>
      </div>

      {/* Step Size Selector */}
      <div style={styles.stepSelector}>
        <span>Step size: </span>
        {stepOptions.map(option => (
          <button
            key={option}
            onClick={() => setStep(option)}
            style={{
              ...styles.stepButton,
              backgroundColor: step === option ? "#3b82f6" : "#e5e7eb",
              color: step === option ? "#fff" : "#374151",
            }}
          >
            {option}
          </button>
        ))}
        <input
          type="number"
          min={1}
          max={max - min}
          value={step}
          onChange={e => {
            const val = parseInt(e.target.value, 10);
            if (!isNaN(val) && val > 0) setStep(val);
          }}
          style={styles.stepInput}
        />
      </div>

      {/* History */}
      <div style={styles.history}>
        <h3>History (last 10)</h3>
        <div style={styles.historyValues}>
          {history.map((value, index) => (
            <span
              key={index}
              style={{
                ...styles.historyItem,
                opacity: 0.3 + (index / history.length) * 0.7,
              }}
            >
              {value}
            </span>
          ))}
        </div>
      </div>
    </div>
  );
}

const styles = {
  container: {
    maxWidth: "400px",
    margin: "40px auto",
    padding: "24px",
    borderRadius: "12px",
    boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1)",
    fontFamily: "system-ui, -apple-system, sans-serif",
    border: "1px solid #e5e7eb",
  },
  heading: {
    textAlign: "center",
    margin: "0 0 20px 0",
    fontSize: "20px",
    color: "#111827",
  },
  countDisplay: {
    textAlign: "center",
    marginBottom: "16px",
  },
  count: {
    fontSize: "64px",
    fontWeight: "bold",
    fontFamily: "monospace",
    transition: "color 0.2s",
  },
  range: {
    fontSize: "14px",
    color: "#6b7280",
    marginTop: "4px",
  },
  progressBar: {
    height: "8px",
    backgroundColor: "#e5e7eb",
    borderRadius: "4px",
    overflow: "hidden",
    marginBottom: "20px",
  },
  progressFill: {
    height: "100%",
    borderRadius: "4px",
    transition: "width 0.2s, background-color 0.2s",
  },
  controls: {
    display: "flex",
    justifyContent: "center",
    gap: "8px",
    marginBottom: "16px",
  },
  button: {
    padding: "10px 20px",
    fontSize: "16px",
    border: "1px solid #d1d5db",
    borderRadius: "8px",
    cursor: "pointer",
    backgroundColor: "#f9fafb",
  },
  resetButton: {
    backgroundColor: "#fef2f2",
    borderColor: "#fca5a5",
    color: "#dc2626",
  },
  stepSelector: {
    display: "flex",
    alignItems: "center",
    gap: "8px",
    marginBottom: "16px",
    flexWrap: "wrap",
  },
  stepButton: {
    padding: "6px 12px",
    border: "none",
    borderRadius: "6px",
    cursor: "pointer",
    fontSize: "14px",
  },
  stepInput: {
    width: "60px",
    padding: "6px",
    borderRadius: "6px",
    border: "1px solid #d1d5db",
    fontSize: "14px",
  },
  history: {
    marginTop: "16px",
  },
  historyValues: {
    display: "flex",
    gap: "8px",
    flexWrap: "wrap",
  },
  historyItem: {
    padding: "4px 8px",
    backgroundColor: "#eff6ff",
    borderRadius: "4px",
    fontSize: "14px",
    fontFamily: "monospace",
  },
};

export default InteractiveCounter;

State Concepts Used

  • useState for count, step, and history
  • Derived values for isAtMin, isAtMax, canIncrement, canDecrement, displayColor, percentage
  • Functional updates for history (prev => [...])
  • Immutable array updates for history (slice to keep last 10)
  • Batching in updateCount (two state updates, one re-render)

Enhancements to Try

  1. Add keyboard shortcuts (arrow keys for increment/decrement)
  2. Add an undo button using the history
  3. Add animation when the count changes
  4. Persist count to localStorage
  5. Add a "random" button that sets a random count

Project 2: Toggle Switch UI

Requirements

Build a reusable toggle switch component:

  • Visual toggle that slides between on/off
  • Theme toggler (light/dark mode)
  • Sidebar toggle (open/close)
  • Multiple independent toggles on one page
  • Accessible (keyboard navigable, ARIA attributes)
  • Animated transition

Planning: Identifying State

DataState?Why
Toggle on/offYesChanges on user interaction
Theme valueYes"light" or "dark"
Sidebar openYestrue/false
Toggle labelNoDerived from on/off state
CSS classesNoDerived from state

Implementation

import { useState } from "react";

// Reusable Toggle Switch Component
function ToggleSwitch({
  isOn,
  onToggle,
  label,
  onLabel = "ON",
  offLabel = "OFF",
  size = "medium",
}) {
  const sizes = {
    small: { width: 40, height: 22, circle: 16, translate: 18, fontSize: "10px" },
    medium: { width: 56, height: 28, circle: 22, translate: 28, fontSize: "12px" },
    large: { width: 72, height: 36, circle: 28, translate: 36, fontSize: "14px" },
  };

  const s = sizes[size];

  return (
    <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
      {label && <span style={{ fontSize: "14px", color: "#374151" }}>{label}</span>}
      <button
        role="switch"
        aria-checked={isOn}
        aria-label={label || (isOn ? onLabel : offLabel)}
        onClick={onToggle}
        onKeyDown={e => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            onToggle();
          }
        }}
        style={{
          width: `${s.width}px`,
          height: `${s.height}px`,
          borderRadius: `${s.height}px`,
          border: "none",
          cursor: "pointer",
          backgroundColor: isOn ? "#3b82f6" : "#d1d5db",
          position: "relative",
          transition: "background-color 0.2s ease",
          padding: 0,
        }}
      >
        <div
          style={{
            width: `${s.circle}px`,
            height: `${s.circle}px`,
            borderRadius: "50%",
            backgroundColor: "#fff",
            position: "absolute",
            top: `${(s.height - s.circle) / 2}px`,
            left: `${(s.height - s.circle) / 2}px`,
            transform: isOn ? `translateX(${s.translate}px)` : "translateX(0)",
            transition: "transform 0.2s ease",
            boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
          }}
        />
      </button>
      <span style={{ fontSize: s.fontSize, color: "#6b7280", minWidth: "24px" }}>
        {isOn ? onLabel : offLabel}
      </span>
    </div>
  );
}

// Theme Toggler
function ThemeToggler() {
  const [theme, setTheme] = useState("light");

  // Derived
  const isDark = theme === "dark";

  function toggleTheme() {
    setTheme(prev => (prev === "light" ? "dark" : "light"));
  }

  const containerStyle = {
    padding: "24px",
    borderRadius: "12px",
    backgroundColor: isDark ? "#1f2937" : "#ffffff",
    color: isDark ? "#f9fafb" : "#111827",
    border: `1px solid ${isDark ? "#374151" : "#e5e7eb"}`,
    transition: "all 0.3s ease",
    marginBottom: "16px",
  };

  return (
    <div style={containerStyle}>
      <h3 style={{ margin: "0 0 16px 0" }}>Theme Toggler</h3>
      <ToggleSwitch
        isOn={isDark}
        onToggle={toggleTheme}
        label="Dark Mode"
        onLabel="Dark"
        offLabel="Light"
      />
      <p style={{ marginTop: "12px", fontSize: "14px" }}>
        Current theme: <strong>{theme}</strong>
      </p>
      <div style={{
        marginTop: "12px",
        padding: "12px",
        borderRadius: "8px",
        backgroundColor: isDark ? "#374151" : "#f3f4f6",
      }}>
        Sample content that adapts to the theme.
      </div>
    </div>
  );
}

// Sidebar Toggle
function SidebarToggle() {
  const [isSidebarOpen, setIsSidebarOpen] = useState(true);

  // Derived
  const sidebarWidth = isSidebarOpen ? "240px" : "0px";
  const contentMargin = isSidebarOpen ? "240px" : "0px";

  return (
    <div style={{ position: "relative", border: "1px solid #e5e7eb", borderRadius: "12px", overflow: "hidden", height: "300px" }}>
      {/* Sidebar */}
      <div style={{
        position: "absolute",
        left: 0,
        top: 0,
        bottom: 0,
        width: sidebarWidth,
        backgroundColor: "#1f2937",
        color: "#fff",
        transition: "width 0.3s ease",
        overflow: "hidden",
        zIndex: 10,
      }}>
        <div style={{ padding: "16px", whiteSpace: "nowrap" }}>
          <h3 style={{ margin: "0 0 16px 0" }}>Sidebar</h3>
          <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
            <li style={{ padding: "8px 0" }}>Dashboard</li>
            <li style={{ padding: "8px 0" }}>Projects</li>
            <li style={{ padding: "8px 0" }}>Settings</li>
            <li style={{ padding: "8px 0" }}>Profile</li>
          </ul>
        </div>
      </div>

      {/* Main Content */}
      <div style={{
        marginLeft: contentMargin,
        padding: "16px",
        transition: "margin-left 0.3s ease",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "16px" }}>
          <ToggleSwitch
            isOn={isSidebarOpen}
            onToggle={() => setIsSidebarOpen(prev => !prev)}
            label="Sidebar"
            onLabel="Open"
            offLabel="Closed"
            size="small"
          />
        </div>
        <h3 style={{ margin: "0 0 8px 0" }}>Main Content</h3>
        <p style={{ color: "#6b7280", fontSize: "14px" }}>
          The sidebar is {isSidebarOpen ? "open" : "closed"}.
          Click the toggle to {isSidebarOpen ? "close" : "open"} it.
        </p>
      </div>
    </div>
  );
}

// Multiple Toggles Demo
function ToggleSwitchDemo() {
  const [notifications, setNotifications] = useState(true);
  const [emailAlerts, setEmailAlerts] = useState(false);
  const [autoSave, setAutoSave] = useState(true);
  const [analytics, setAnalytics] = useState(false);

  // Derived
  const activeCount = [notifications, emailAlerts, autoSave, analytics].filter(Boolean).length;
  const allOn = activeCount === 4;
  const allOff = activeCount === 0;

  function toggleAll() {
    const newValue = !allOn;
    setNotifications(newValue);
    setEmailAlerts(newValue);
    setAutoSave(newValue);
    setAnalytics(newValue);
    // All four updates are batched into one re-render
  }

  return (
    <div style={{
      maxWidth: "500px",
      margin: "40px auto",
      fontFamily: "system-ui, -apple-system, sans-serif",
    }}>
      <h2 style={{ marginBottom: "24px" }}>Toggle Switch UI</h2>

      <ThemeToggler />

      <div style={{
        padding: "24px",
        borderRadius: "12px",
        border: "1px solid #e5e7eb",
        marginBottom: "16px",
      }}>
        <h3 style={{ margin: "0 0 16px 0" }}>Settings ({activeCount}/4 active)</h3>

        <div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
          <ToggleSwitch
            isOn={notifications}
            onToggle={() => setNotifications(prev => !prev)}
            label="Push Notifications"
          />
          <ToggleSwitch
            isOn={emailAlerts}
            onToggle={() => setEmailAlerts(prev => !prev)}
            label="Email Alerts"
          />
          <ToggleSwitch
            isOn={autoSave}
            onToggle={() => setAutoSave(prev => !prev)}
            label="Auto Save"
          />
          <ToggleSwitch
            isOn={analytics}
            onToggle={() => setAnalytics(prev => !prev)}
            label="Analytics"
          />
        </div>

        <div style={{ marginTop: "16px", paddingTop: "16px", borderTop: "1px solid #e5e7eb" }}>
          <button
            onClick={toggleAll}
            style={{
              padding: "8px 16px",
              border: "1px solid #d1d5db",
              borderRadius: "6px",
              cursor: "pointer",
              backgroundColor: "#f9fafb",
              fontSize: "14px",
            }}
          >
            {allOn ? "Disable All" : allOff ? "Enable All" : "Enable All"}
          </button>
        </div>
      </div>

      <SidebarToggle />
    </div>
  );
}

export default ToggleSwitchDemo;

State Concepts Used

  • Boolean state for each toggle
  • Derived values for activeCount, allOn, allOff, isDark, sidebarWidth
  • State isolation -- each toggle is independent
  • Batching in toggleAll (four state updates, one re-render)
  • Functional updates with prev => !prev for toggling
  • Controlled component pattern -- ToggleSwitch receives state from parent

Enhancements to Try

  1. Add a "toggle all" switch that uses derived state to show its own on/off
  2. Persist preferences to localStorage
  3. Add transition animations for the toggle knob
  4. Add color customization (different colors for different toggles)

Project 3: Temperature Converter

Requirements

Build a temperature converter:

  • Input field for each unit (Celsius, Fahrenheit, Kelvin)
  • Editing any field updates the others in real-time
  • Visual thermometer that changes with temperature
  • Temperature description ("Freezing", "Cold", "Comfortable", "Warm", "Hot")
  • History of conversions
  • Preset temperatures (body temp, boiling water, absolute zero)

Planning: State Design

The key question: which temperature unit is the "source of truth"?

Approach: Store only ONE value (Celsius). Derive everything else.

DataState?Derived from?
Celsius valueYes (single source)--
Fahrenheit valueNocelsius * 9/5 + 32
Kelvin valueNocelsius + 273.15
DescriptionNoBased on celsius ranges
Thermometer levelNoPercentage from celsius
Conversion historyYes--

Implementation

import { useState } from "react";

function TemperatureConverter() {
  const [celsius, setCelsius] = useState(20);
  const [history, setHistory] = useState([]);
  const [activeInput, setActiveInput] = useState("celsius");

  // Derived conversions
  const fahrenheit = parseFloat(((celsius * 9) / 5 + 32).toFixed(2));
  const kelvin = parseFloat((celsius + 273.15).toFixed(2));

  // Derived description
  const description = getDescription(celsius);
  const descriptionColor = getDescriptionColor(celsius);

  // Derived thermometer level (0-100, where 0 = -40C and 100 = 50C)
  const thermometerLevel = Math.max(0, Math.min(100, ((celsius + 40) / 90) * 100));

  // Handler for Celsius input
  function handleCelsiusChange(value) {
    const parsed = parseFloat(value);
    if (!isNaN(parsed)) {
      setCelsius(parsed);
      setActiveInput("celsius");
    } else if (value === "" || value === "-") {
      setCelsius(0);
    }
  }

  // Handler for Fahrenheit input
  function handleFahrenheitChange(value) {
    const parsed = parseFloat(value);
    if (!isNaN(parsed)) {
      setCelsius(parseFloat(((parsed - 32) * 5 / 9).toFixed(2)));
      setActiveInput("fahrenheit");
    }
  }

  // Handler for Kelvin input
  function handleKelvinChange(value) {
    const parsed = parseFloat(value);
    if (!isNaN(parsed)) {
      setCelsius(parseFloat((parsed - 273.15).toFixed(2)));
      setActiveInput("kelvin");
    }
  }

  // Save to history
  function saveConversion() {
    setHistory(prev => [
      { celsius, fahrenheit, kelvin, timestamp: Date.now() },
      ...prev.slice(0, 9),
    ]);
  }

  // Preset temperatures
  const presets = [
    { label: "Absolute Zero", celsius: -273.15 },
    { label: "Freezing Point", celsius: 0 },
    { label: "Room Temperature", celsius: 22 },
    { label: "Body Temperature", celsius: 37 },
    { label: "Boiling Point", celsius: 100 },
  ];

  return (
    <div style={styles.container}>
      <h2 style={styles.heading}>Temperature Converter</h2>

      {/* Thermometer Visual */}
      <div style={styles.thermometerContainer}>
        <div style={styles.thermometer}>
          <div
            style={{
              ...styles.thermometerFill,
              height: `${thermometerLevel}%`,
              backgroundColor: descriptionColor,
            }}
          />
        </div>
        <div style={{ textAlign: "center" }}>
          <span style={{ fontSize: "18px", fontWeight: "bold", color: descriptionColor }}>
            {description}
          </span>
        </div>
      </div>

      {/* Input Fields */}
      <div style={styles.inputGrid}>
        <div style={styles.inputGroup}>
          <label style={styles.label}>Celsius</label>
          <input
            type="number"
            value={celsius}
            onChange={e => handleCelsiusChange(e.target.value)}
            onFocus={() => setActiveInput("celsius")}
            style={{
              ...styles.input,
              borderColor: activeInput === "celsius" ? "#3b82f6" : "#d1d5db",
            }}
          />
          <span style={styles.unit}>&#176;C</span>
        </div>

        <div style={styles.inputGroup}>
          <label style={styles.label}>Fahrenheit</label>
          <input
            type="number"
            value={fahrenheit}
            onChange={e => handleFahrenheitChange(e.target.value)}
            onFocus={() => setActiveInput("fahrenheit")}
            style={{
              ...styles.input,
              borderColor: activeInput === "fahrenheit" ? "#3b82f6" : "#d1d5db",
            }}
          />
          <span style={styles.unit}>&#176;F</span>
        </div>

        <div style={styles.inputGroup}>
          <label style={styles.label}>Kelvin</label>
          <input
            type="number"
            value={kelvin}
            onChange={e => handleKelvinChange(e.target.value)}
            onFocus={() => setActiveInput("kelvin")}
            style={{
              ...styles.input,
              borderColor: activeInput === "kelvin" ? "#3b82f6" : "#d1d5db",
            }}
          />
          <span style={styles.unit}>K</span>
        </div>
      </div>

      {/* Presets */}
      <div style={styles.presets}>
        <span style={{ fontSize: "14px", color: "#6b7280" }}>Presets: </span>
        {presets.map(preset => (
          <button
            key={preset.label}
            onClick={() => setCelsius(preset.celsius)}
            style={styles.presetButton}
          >
            {preset.label}
          </button>
        ))}
      </div>

      {/* Save Button */}
      <button onClick={saveConversion} style={styles.saveButton}>
        Save Conversion
      </button>

      {/* History */}
      {history.length > 0 && (
        <div style={styles.historySection}>
          <h3 style={{ margin: "0 0 8px 0", fontSize: "16px" }}>History</h3>
          <table style={styles.table}>
            <thead>
              <tr>
                <th style={styles.th}>Celsius</th>
                <th style={styles.th}>Fahrenheit</th>
                <th style={styles.th}>Kelvin</th>
              </tr>
            </thead>
            <tbody>
              {history.map((entry, i) => (
                <tr key={entry.timestamp}>
                  <td style={styles.td}>{entry.celsius}&#176;C</td>
                  <td style={styles.td}>{entry.fahrenheit}&#176;F</td>
                  <td style={styles.td}>{entry.kelvin} K</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

function getDescription(celsius) {
  if (celsius <= -40) return "Extreme Cold";
  if (celsius <= -10) return "Very Cold";
  if (celsius <= 0) return "Freezing";
  if (celsius <= 10) return "Cold";
  if (celsius <= 20) return "Cool";
  if (celsius <= 25) return "Comfortable";
  if (celsius <= 30) return "Warm";
  if (celsius <= 35) return "Hot";
  if (celsius <= 45) return "Very Hot";
  return "Extreme Heat";
}

function getDescriptionColor(celsius) {
  if (celsius <= 0) return "#3b82f6";   // blue
  if (celsius <= 15) return "#06b6d4";  // cyan
  if (celsius <= 25) return "#22c55e";  // green
  if (celsius <= 35) return "#f59e0b";  // amber
  return "#ef4444";                      // red
}

const styles = {
  container: {
    maxWidth: "480px",
    margin: "40px auto",
    padding: "24px",
    borderRadius: "12px",
    boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1)",
    fontFamily: "system-ui, -apple-system, sans-serif",
    border: "1px solid #e5e7eb",
  },
  heading: {
    textAlign: "center",
    margin: "0 0 20px 0",
    fontSize: "20px",
    color: "#111827",
  },
  thermometerContainer: {
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    gap: "16px",
    marginBottom: "24px",
  },
  thermometer: {
    width: "24px",
    height: "120px",
    backgroundColor: "#e5e7eb",
    borderRadius: "12px",
    overflow: "hidden",
    position: "relative",
  },
  thermometerFill: {
    position: "absolute",
    bottom: 0,
    left: 0,
    right: 0,
    borderRadius: "12px",
    transition: "height 0.3s, background-color 0.3s",
  },
  inputGrid: {
    display: "flex",
    flexDirection: "column",
    gap: "12px",
    marginBottom: "16px",
  },
  inputGroup: {
    display: "flex",
    alignItems: "center",
    gap: "8px",
  },
  label: {
    width: "100px",
    fontSize: "14px",
    fontWeight: "500",
    color: "#374151",
  },
  input: {
    flex: 1,
    padding: "8px 12px",
    borderRadius: "8px",
    border: "2px solid #d1d5db",
    fontSize: "16px",
    fontFamily: "monospace",
    transition: "border-color 0.2s",
    outline: "none",
  },
  unit: {
    fontSize: "14px",
    color: "#6b7280",
    width: "24px",
  },
  presets: {
    display: "flex",
    flexWrap: "wrap",
    gap: "6px",
    alignItems: "center",
    marginBottom: "16px",
  },
  presetButton: {
    padding: "4px 10px",
    fontSize: "12px",
    border: "1px solid #d1d5db",
    borderRadius: "6px",
    cursor: "pointer",
    backgroundColor: "#f9fafb",
  },
  saveButton: {
    width: "100%",
    padding: "10px",
    fontSize: "14px",
    border: "none",
    borderRadius: "8px",
    cursor: "pointer",
    backgroundColor: "#3b82f6",
    color: "#fff",
    marginBottom: "16px",
  },
  historySection: {
    marginTop: "8px",
  },
  table: {
    width: "100%",
    borderCollapse: "collapse",
    fontSize: "14px",
  },
  th: {
    textAlign: "left",
    padding: "6px 8px",
    borderBottom: "2px solid #e5e7eb",
    fontSize: "12px",
    color: "#6b7280",
    textTransform: "uppercase",
  },
  td: {
    padding: "6px 8px",
    borderBottom: "1px solid #f3f4f6",
    fontFamily: "monospace",
  },
};

export default TemperatureConverter;

State Concepts Used

  • Single source of truth: Only Celsius is state. Fahrenheit and Kelvin are derived.
  • Derived values: fahrenheit, kelvin, description, descriptionColor, thermometerLevel
  • Inverse conversions: When user types Fahrenheit, we convert back to Celsius (the source)
  • Immutable array updates: History uses spread + slice

Project 4: Character Counter Textarea

Requirements

  • Textarea with character limit
  • Live character count display
  • Word count
  • Line count
  • Reading time estimate
  • Progress bar showing usage
  • Warning when approaching limit
  • Option to toggle between counting with/without spaces

Implementation

import { useState } from "react";

function CharacterCounter({ maxLength = 280 }) {
  const [text, setText] = useState("");
  const [countSpaces, setCountSpaces] = useState(true);

  // Derived values
  const charCount = countSpaces ? text.length : text.replace(/\s/g, "").length;
  const wordCount = text.trim() === "" ? 0 : text.trim().split(/\s+/).length;
  const lineCount = text === "" ? 0 : text.split("\n").length;
  const sentenceCount = text.trim() === ""
    ? 0
    : text.split(/[.!?]+/).filter(s => s.trim()).length;

  const readingTimeSeconds = Math.ceil(wordCount / 3.5); // ~210 words/min
  const readingTimeDisplay = readingTimeSeconds < 60
    ? `${readingTimeSeconds}s`
    : `${Math.floor(readingTimeSeconds / 60)}m ${readingTimeSeconds % 60}s`;

  const percentage = (charCount / maxLength) * 100;
  const remaining = maxLength - charCount;
  const isOverLimit = charCount > maxLength;
  const isNearLimit = remaining <= 20 && remaining > 0;

  const progressColor = isOverLimit
    ? "#ef4444"
    : isNearLimit
    ? "#f59e0b"
    : "#3b82f6";

  // Most frequent word
  const wordFrequency = text
    .toLowerCase()
    .split(/\s+/)
    .filter(w => w.length > 2)
    .reduce((acc, word) => {
      acc[word] = (acc[word] || 0) + 1;
      return acc;
    }, {});

  const topWord = Object.entries(wordFrequency)
    .sort((a, b) => b[1] - a[1])[0];

  function handleChange(e) {
    setText(e.target.value);
  }

  function handleClear() {
    setText("");
  }

  function handleCopy() {
    navigator.clipboard.writeText(text);
  }

  return (
    <div style={cStyles.container}>
      <h2 style={cStyles.heading}>Character Counter</h2>

      {/* Textarea */}
      <div style={{ position: "relative" }}>
        <textarea
          value={text}
          onChange={handleChange}
          placeholder="Start typing..."
          rows={8}
          style={{
            ...cStyles.textarea,
            borderColor: isOverLimit ? "#ef4444" : "#d1d5db",
          }}
        />
      </div>

      {/* Progress Bar */}
      <div style={cStyles.progressBar}>
        <div
          style={{
            ...cStyles.progressFill,
            width: `${Math.min(percentage, 100)}%`,
            backgroundColor: progressColor,
          }}
        />
      </div>

      {/* Character Counter Display */}
      <div style={cStyles.counterRow}>
        <span style={{ color: isOverLimit ? "#ef4444" : isNearLimit ? "#f59e0b" : "#6b7280" }}>
          {charCount} / {maxLength}
          {isOverLimit && ` (${Math.abs(remaining)} over)`}
          {isNearLimit && ` (${remaining} left)`}
        </span>
        <div style={{ display: "flex", gap: "8px" }}>
          <button onClick={handleCopy} style={cStyles.actionButton}>Copy</button>
          <button onClick={handleClear} style={cStyles.actionButton}>Clear</button>
        </div>
      </div>

      {/* Count Spaces Toggle */}
      <div style={cStyles.toggleRow}>
        <label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px" }}>
          <input
            type="checkbox"
            checked={countSpaces}
            onChange={e => setCountSpaces(e.target.checked)}
          />
          Count spaces
        </label>
      </div>

      {/* Stats Grid */}
      <div style={cStyles.statsGrid}>
        <div style={cStyles.statBox}>
          <div style={cStyles.statValue}>{charCount}</div>
          <div style={cStyles.statLabel}>Characters</div>
        </div>
        <div style={cStyles.statBox}>
          <div style={cStyles.statValue}>{wordCount}</div>
          <div style={cStyles.statLabel}>Words</div>
        </div>
        <div style={cStyles.statBox}>
          <div style={cStyles.statValue}>{sentenceCount}</div>
          <div style={cStyles.statLabel}>Sentences</div>
        </div>
        <div style={cStyles.statBox}>
          <div style={cStyles.statValue}>{lineCount}</div>
          <div style={cStyles.statLabel}>Lines</div>
        </div>
        <div style={cStyles.statBox}>
          <div style={cStyles.statValue}>{readingTimeDisplay}</div>
          <div style={cStyles.statLabel}>Reading Time</div>
        </div>
        <div style={cStyles.statBox}>
          <div style={cStyles.statValue}>{topWord ? topWord[0] : "-"}</div>
          <div style={cStyles.statLabel}>
            Top Word{topWord ? ` (${topWord[1]}x)` : ""}
          </div>
        </div>
      </div>
    </div>
  );
}

const cStyles = {
  container: {
    maxWidth: "520px",
    margin: "40px auto",
    padding: "24px",
    borderRadius: "12px",
    boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1)",
    fontFamily: "system-ui, -apple-system, sans-serif",
    border: "1px solid #e5e7eb",
  },
  heading: {
    textAlign: "center",
    margin: "0 0 20px 0",
    fontSize: "20px",
    color: "#111827",
  },
  textarea: {
    width: "100%",
    padding: "12px",
    fontSize: "15px",
    borderRadius: "8px",
    border: "2px solid #d1d5db",
    resize: "vertical",
    fontFamily: "system-ui, sans-serif",
    lineHeight: 1.6,
    outline: "none",
    transition: "border-color 0.2s",
    boxSizing: "border-box",
  },
  progressBar: {
    height: "4px",
    backgroundColor: "#e5e7eb",
    borderRadius: "2px",
    overflow: "hidden",
    margin: "8px 0",
  },
  progressFill: {
    height: "100%",
    borderRadius: "2px",
    transition: "width 0.15s, background-color 0.15s",
  },
  counterRow: {
    display: "flex",
    justifyContent: "space-between",
    alignItems: "center",
    fontSize: "13px",
    marginBottom: "8px",
  },
  actionButton: {
    padding: "4px 10px",
    fontSize: "12px",
    border: "1px solid #d1d5db",
    borderRadius: "4px",
    cursor: "pointer",
    backgroundColor: "#f9fafb",
  },
  toggleRow: {
    marginBottom: "16px",
    color: "#6b7280",
  },
  statsGrid: {
    display: "grid",
    gridTemplateColumns: "repeat(3, 1fr)",
    gap: "8px",
  },
  statBox: {
    padding: "12px",
    borderRadius: "8px",
    backgroundColor: "#f9fafb",
    textAlign: "center",
    border: "1px solid #f3f4f6",
  },
  statValue: {
    fontSize: "20px",
    fontWeight: "bold",
    color: "#111827",
    fontFamily: "monospace",
  },
  statLabel: {
    fontSize: "11px",
    color: "#6b7280",
    marginTop: "4px",
    textTransform: "uppercase",
  },
};

export default CharacterCounter;

State Concepts Used

  • Minimal state: Only text and countSpaces are state
  • Derived values: charCount, wordCount, lineCount, sentenceCount, readingTime, percentage, remaining, isOverLimit, isNearLimit, progressColor, topWord
  • No useEffect needed: Everything computes from the text on each render

Project 5: Traffic Light Simulator

Requirements

  • Three lights (red, yellow, green) that cycle automatically
  • Manual mode: click to advance to next light
  • Auto mode: lights cycle on a timer
  • Configurable durations for each light
  • Pedestrian crossing button
  • Visual indicator of next light and time remaining

Implementation

import { useState, useEffect, useRef } from "react";

function TrafficLightSimulator() {
  const [currentLight, setCurrentLight] = useState("red");
  const [mode, setMode] = useState("manual"); // "manual" | "auto"
  const [durations, setDurations] = useState({
    red: 5000,
    yellow: 2000,
    green: 4000,
  });
  const [timeRemaining, setTimeRemaining] = useState(0);
  const [pedestrianRequest, setPedestrianRequest] = useState(false);
  const [pedestrianActive, setPedestrianActive] = useState(false);

  const intervalRef = useRef(null);
  const timerRef = useRef(null);

  // Derived values
  const lightSequence = ["red", "green", "yellow"];
  const currentIndex = lightSequence.indexOf(currentLight);
  const nextLight = lightSequence[(currentIndex + 1) % lightSequence.length];

  const isRed = currentLight === "red";
  const isYellow = currentLight === "yellow";
  const isGreen = currentLight === "green";

  const pedestrianCanCross = isRed && pedestrianActive;
  const pedestrianSignal = pedestrianCanCross ? "WALK" : "WAIT";
  const pedestrianColor = pedestrianCanCross ? "#16a34a" : "#ef4444";

  const timeRemainingDisplay = (timeRemaining / 1000).toFixed(1);

  function advanceLight() {
    setCurrentLight(prev => {
      const idx = lightSequence.indexOf(prev);
      const next = lightSequence[(idx + 1) % lightSequence.length];

      // Handle pedestrian crossing
      if (next === "red" && pedestrianRequest) {
        setPedestrianActive(true);
        setPedestrianRequest(false);
      } else {
        setPedestrianActive(false);
      }

      return next;
    });
  }

  // Auto mode timer
  useEffect(() => {
    if (mode !== "auto") {
      clearInterval(intervalRef.current);
      clearInterval(timerRef.current);
      setTimeRemaining(0);
      return;
    }

    const duration = durations[currentLight];
    setTimeRemaining(duration);

    // Countdown timer (updates every 100ms)
    timerRef.current = setInterval(() => {
      setTimeRemaining(prev => Math.max(0, prev - 100));
    }, 100);

    // Light change timer
    intervalRef.current = setTimeout(() => {
      advanceLight();
    }, duration);

    return () => {
      clearTimeout(intervalRef.current);
      clearInterval(timerRef.current);
    };
  }, [currentLight, mode, durations]);

  function handlePedestrianButton() {
    if (!pedestrianActive && !pedestrianRequest) {
      setPedestrianRequest(true);
    }
  }

  function handleDurationChange(light, value) {
    setDurations(prev => ({
      ...prev,
      [light]: Math.max(500, parseInt(value, 10) || 1000),
    }));
  }

  function toggleMode() {
    setMode(prev => (prev === "manual" ? "auto" : "manual"));
  }

  return (
    <div style={tStyles.container}>
      <h2 style={tStyles.heading}>Traffic Light Simulator</h2>

      {/* Traffic Light */}
      <div style={tStyles.trafficLight}>
        <div style={tStyles.pole}>
          {/* Red Light */}
          <div
            style={{
              ...tStyles.light,
              backgroundColor: isRed ? "#ef4444" : "#4a1515",
              boxShadow: isRed ? "0 0 20px #ef4444, 0 0 40px rgba(239,68,68,0.3)" : "none",
            }}
          />
          {/* Yellow Light */}
          <div
            style={{
              ...tStyles.light,
              backgroundColor: isYellow ? "#eab308" : "#4a4215",
              boxShadow: isYellow ? "0 0 20px #eab308, 0 0 40px rgba(234,179,8,0.3)" : "none",
            }}
          />
          {/* Green Light */}
          <div
            style={{
              ...tStyles.light,
              backgroundColor: isGreen ? "#22c55e" : "#154a25",
              boxShadow: isGreen ? "0 0 20px #22c55e, 0 0 40px rgba(34,197,94,0.3)" : "none",
            }}
          />
        </div>

        {/* Pedestrian Signal */}
        <div style={tStyles.pedestrianSignal}>
          <div style={{
            ...tStyles.pedestrianDisplay,
            color: pedestrianColor,
            borderColor: pedestrianColor,
          }}>
            {pedestrianSignal}
          </div>
          <button
            onClick={handlePedestrianButton}
            disabled={pedestrianActive || pedestrianRequest}
            style={{
              ...tStyles.pedestrianButton,
              backgroundColor: pedestrianRequest ? "#fef2f2" : "#f9fafb",
            }}
          >
            {pedestrianRequest ? "Requested" : pedestrianActive ? "Crossing..." : "Request Crossing"}
          </button>
        </div>
      </div>

      {/* Status Display */}
      <div style={tStyles.statusBar}>
        <div>
          Current: <strong style={{ textTransform: "capitalize" }}>{currentLight}</strong>
        </div>
        <div>
          Next: <span style={{ textTransform: "capitalize" }}>{nextLight}</span>
        </div>
        {mode === "auto" && timeRemaining > 0 && (
          <div>Time: {timeRemainingDisplay}s</div>
        )}
      </div>

      {/* Mode Toggle */}
      <div style={tStyles.modeToggle}>
        <button
          onClick={toggleMode}
          style={{
            ...tStyles.modeButton,
            backgroundColor: mode === "auto" ? "#3b82f6" : "#f9fafb",
            color: mode === "auto" ? "#fff" : "#374151",
          }}
        >
          {mode === "auto" ? "Switch to Manual" : "Switch to Auto"}
        </button>
        {mode === "manual" && (
          <button onClick={advanceLight} style={tStyles.advanceButton}>
            Next Light ({nextLight})
          </button>
        )}
      </div>

      {/* Duration Controls (auto mode only) */}
      {mode === "auto" && (
        <div style={tStyles.durationsSection}>
          <h3 style={{ margin: "0 0 12px 0", fontSize: "14px" }}>Light Durations</h3>
          {["red", "yellow", "green"].map(light => (
            <div key={light} style={tStyles.durationRow}>
              <span style={{
                ...tStyles.durationLabel,
                color: light === "red" ? "#ef4444" : light === "yellow" ? "#eab308" : "#22c55e",
              }}>
                {light.charAt(0).toUpperCase() + light.slice(1)}
              </span>
              <input
                type="range"
                min={500}
                max={10000}
                step={500}
                value={durations[light]}
                onChange={e => handleDurationChange(light, e.target.value)}
                style={tStyles.slider}
              />
              <span style={tStyles.durationValue}>
                {(durations[light] / 1000).toFixed(1)}s
              </span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

const tStyles = {
  container: {
    maxWidth: "400px",
    margin: "40px auto",
    padding: "24px",
    borderRadius: "12px",
    boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1)",
    fontFamily: "system-ui, -apple-system, sans-serif",
    border: "1px solid #e5e7eb",
    backgroundColor: "#fafafa",
  },
  heading: {
    textAlign: "center",
    margin: "0 0 24px 0",
    fontSize: "20px",
    color: "#111827",
  },
  trafficLight: {
    display: "flex",
    justifyContent: "center",
    alignItems: "flex-start",
    gap: "32px",
    marginBottom: "24px",
  },
  pole: {
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    gap: "8px",
    padding: "16px 12px",
    backgroundColor: "#1f2937",
    borderRadius: "12px",
  },
  light: {
    width: "48px",
    height: "48px",
    borderRadius: "50%",
    transition: "background-color 0.3s, box-shadow 0.3s",
  },
  pedestrianSignal: {
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    gap: "8px",
  },
  pedestrianDisplay: {
    width: "80px",
    height: "60px",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    borderRadius: "8px",
    border: "3px solid",
    fontWeight: "bold",
    fontSize: "14px",
    fontFamily: "monospace",
    backgroundColor: "#111827",
    transition: "color 0.3s, border-color 0.3s",
  },
  pedestrianButton: {
    padding: "8px 12px",
    fontSize: "12px",
    border: "1px solid #d1d5db",
    borderRadius: "6px",
    cursor: "pointer",
  },
  statusBar: {
    display: "flex",
    justifyContent: "space-between",
    padding: "12px",
    backgroundColor: "#f3f4f6",
    borderRadius: "8px",
    fontSize: "14px",
    marginBottom: "16px",
  },
  modeToggle: {
    display: "flex",
    gap: "8px",
    marginBottom: "16px",
  },
  modeButton: {
    flex: 1,
    padding: "10px",
    fontSize: "14px",
    border: "1px solid #d1d5db",
    borderRadius: "8px",
    cursor: "pointer",
    transition: "all 0.2s",
  },
  advanceButton: {
    flex: 1,
    padding: "10px",
    fontSize: "14px",
    border: "1px solid #d1d5db",
    borderRadius: "8px",
    cursor: "pointer",
    backgroundColor: "#f0fdf4",
    borderColor: "#86efac",
  },
  durationsSection: {
    padding: "16px",
    backgroundColor: "#fff",
    borderRadius: "8px",
    border: "1px solid #e5e7eb",
  },
  durationRow: {
    display: "flex",
    alignItems: "center",
    gap: "8px",
    marginBottom: "8px",
  },
  durationLabel: {
    width: "60px",
    fontSize: "13px",
    fontWeight: "600",
    textTransform: "capitalize",
  },
  slider: {
    flex: 1,
  },
  durationValue: {
    width: "40px",
    fontSize: "13px",
    fontFamily: "monospace",
    textAlign: "right",
  },
};

export default TrafficLightSimulator;

State Concepts Used

  • State machine pattern: currentLight cycles through red -> green -> yellow
  • Derived values: nextLight, isRed/isYellow/isGreen, pedestrianCanCross, pedestrianSignal, timeRemainingDisplay
  • useRef for non-rendered values: intervalRef, timerRef (interval IDs don't affect UI)
  • Object state updates: durations use spread operator for immutable updates
  • Batching: advanceLight updates currentLight, pedestrianActive, and pedestrianRequest in one event handler
  • useEffect for side effects: auto-mode timer lives in useEffect with proper cleanup

Enhancements to Try

  1. Add a log of light changes with timestamps
  2. Add "emergency mode" (all red, flashing)
  3. Add sound effects for each light change
  4. Add a visual countdown ring around the active light
  5. Add configurable pedestrian crossing duration

Key Takeaways

  1. Identify state vs derived values early. Planning prevents storing unnecessary state.
  2. Minimal state = fewer bugs. The counter, temperature converter, and character counter all use 1-3 state variables with many derived values.
  3. Functional updates prevent stale closure bugs. Used in counter history and light advancement.
  4. Immutable updates are essential. Every object and array update creates a new reference.
  5. Refs for non-UI values. Timer IDs, interval IDs, and DOM references go in refs, not state.
  6. useEffect for side effects only. Timers, subscriptions, and external synchronization.
  7. Batching is automatic. Multiple setState calls in handlers = one re-render.
  8. Component composition works. The toggle switch is reusable across theme, sidebar, and settings.
  9. Inline styles work for demos. In production, use CSS modules or Tailwind.
  10. Every project reinforces the same principles. State, derived values, immutability, and re-rendering.

Explain-It Challenge

Pick one of the five projects and explain its state design to someone who hasn't read this chapter. Specifically:

  • What data is stored in state, and why?
  • What data is derived, and why isn't it stored in state?
  • What would go wrong if you stored the derived values in state?

Try using the temperature converter: "We only store Celsius. Fahrenheit and Kelvin are calculated from it. If we stored all three independently, changing one wouldn't automatically update the others, and they'd fall out of sync."


Navigation: ← 2.3.e — Derived State · Next → 2.3 Exercise Questions