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
- Project 1: Interactive Counter
- Project 2: Toggle Switch UI
- Project 3: Temperature Converter
- Project 4: Character Counter Textarea
- Project 5: Traffic Light Simulator
- Key Takeaways
- 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?
| Data | Changes? | Derived? | State? |
|---|---|---|---|
| Current count | Yes | No | Yes |
| Step size | Yes (user selects) | No | Yes |
| Count history | Yes | No | Yes |
| Is at minimum | Yes | Yes (count === min) | No (derived) |
| Is at maximum | Yes | Yes (count === max) | No (derived) |
| Can increment | Yes | Yes (count + step <= max) | No (derived) |
| Can decrement | Yes | Yes (count - step >= min) | No (derived) |
| Display color | Yes | Yes (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
- Add keyboard shortcuts (arrow keys for increment/decrement)
- Add an undo button using the history
- Add animation when the count changes
- Persist count to localStorage
- 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
| Data | State? | Why |
|---|---|---|
| Toggle on/off | Yes | Changes on user interaction |
| Theme value | Yes | "light" or "dark" |
| Sidebar open | Yes | true/false |
| Toggle label | No | Derived from on/off state |
| CSS classes | No | Derived 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 => !prevfor toggling - Controlled component pattern -- ToggleSwitch receives state from parent
Enhancements to Try
- Add a "toggle all" switch that uses derived state to show its own on/off
- Persist preferences to localStorage
- Add transition animations for the toggle knob
- 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.
| Data | State? | Derived from? |
|---|---|---|
| Celsius value | Yes (single source) | -- |
| Fahrenheit value | No | celsius * 9/5 + 32 |
| Kelvin value | No | celsius + 273.15 |
| Description | No | Based on celsius ranges |
| Thermometer level | No | Percentage from celsius |
| Conversion history | Yes | -- |
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}>°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}>°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}°C</td>
<td style={styles.td}>{entry.fahrenheit}°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
textandcountSpacesare 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
- Add a log of light changes with timestamps
- Add "emergency mode" (all red, flashing)
- Add sound effects for each light change
- Add a visual countdown ring around the active light
- Add configurable pedestrian crossing duration
Key Takeaways
- Identify state vs derived values early. Planning prevents storing unnecessary state.
- Minimal state = fewer bugs. The counter, temperature converter, and character counter all use 1-3 state variables with many derived values.
- Functional updates prevent stale closure bugs. Used in counter history and light advancement.
- Immutable updates are essential. Every object and array update creates a new reference.
- Refs for non-UI values. Timer IDs, interval IDs, and DOM references go in refs, not state.
- useEffect for side effects only. Timers, subscriptions, and external synchronization.
- Batching is automatic. Multiple setState calls in handlers = one re-render.
- Component composition works. The toggle switch is reusable across theme, sidebar, and settings.
- Inline styles work for demos. In production, use CSS modules or Tailwind.
- 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