Demo application: Carved Rock Fitness — online shoe store
Table of Contents
- Course Overview
- Deciding How to Manage State
- Local State and Remote State (useState / useEffect)
- URL State and Web Storage (React Router / localStorage)
- Shared, Derived, and Immutable State
- Form State and Validation
- State via Refs (useRef)
- Complex State with useReducer
- Sharing State via Context API
- Quick Reference — When to Use What
1. Course Overview
A typical React application declares dozens of state pieces. This course covers modern state management approaches:
- Declaring, updating, and deriving state
- Sharing data and logic between components
- Managing loading state, errors, and form validation
- Unrendered state via refs
- Choosing between different approaches
Prerequisites: function components, JSX, props.
2. Deciding How to Manage State
2.1 The 8 Ways to Manage State
| # | Approach | Typical Usage |
|---|---|---|
| 1 | URL | Current page, filters, pagination, sorting |
| 2 | Web Storage (localStorage, sessionStorage) | Persistence across reloads |
| 3 | Local Component State (useState) | Component-specific data |
| 4 | Lifted State | Sharing between sibling components via common parent |
| 5 | Derived State | Calculated from existing state, not stored |
| 6 | Refs (useRef) | DOM, unrendered values, timers |
| 7 | Context | Global data shared in component tree |
| 8 | Third-party (React Query, Redux) | Advanced needs, server cache |
Environment variables: for static per-environment configs (
REACT_APP_BASE_URL), use environment variables — they are not React state.
2.2 JavaScript Data Structures for State
| Type | Mutable? | Examples |
|---|---|---|
| Boolean, String, Number | No (primitives) | true, "hello", 42 |
| Object, Array | Yes | {}, [] |
| Map, Set | Yes | Key-value, unique values |
In React, treat state as immutable even for mutable types.
3. Local State and Remote State
3.1 Local and Remote State Flow
flowchart TD
A[Component mounts] --> B[useState initializes state]
B --> C{Need remote data?}
C -->|Yes| D[useEffect triggers fetch]
D --> E{Response OK?}
E -->|Success| F[setData → re-render]
E -->|Error| G[setError → throw]
E -->|Always| H[setLoading false]
F --> I[Display data]
G --> J[ErrorBoundary catches and shows fallback UI]
C -->|No| K[Direct render with local state]
H --> I
3.2 Declaring State with useState
import React, { useState } from "react";
export default function Products() {
const [size, setSize] = useState("");
const filteredProducts = size
? products.filter((p) => p.skus.find((s) => s.size === parseInt(size)))
: products;
return (
<select value={size} onChange={(e) => setSize(e.target.value)}>
<option value="">All sizes</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
);
}
Hooks Rules:
- Only in function components
- Name must start with
use - Call only at root level (not in conditions or loops)
3.3 Fetching with useEffect (async/await)
import React, { useState, useEffect } from "react";
export default function App() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function init() {
try {
const response = await fetch("/api/products?category=shoes");
if (response.ok) {
setProducts(await response.json());
} else {
throw response;
}
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
}
init();
}, []); // empty array = run only on first render
if (error) throw error; // caught by ErrorBoundary
if (loading) return <Spinner />;
return <ProductList products={products} />;
}
3.4 Custom Hook useFetch — Centralizing Fetching
// src/services/useFetch.js
import { useState, useEffect } from "react";
const baseUrl = process.env.REACT_APP_API_BASE_URL;
export default function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function init() {
try {
const response = await fetch(baseUrl + url);
if (response.ok) {
setData(await response.json());
} else {
throw response;
}
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
}
init();
}, [url]);
return { data, error, loading };
}
Consuming in 1 line:
const { data: products, loading, error } = useFetch("products?category=shoes");
3.5 ErrorBoundary
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) return <h1>Something went wrong.</h1>;
return this.props.children;
}
}
3.6 The 4 Approaches for API Calls
| Approach | Description | Recommended? |
|---|---|---|
| Inline | fetch() directly in component | No — hard to test/reuse |
| Centralized | Functions in /services/ imported in component | Good for starting |
| Custom Hook | useFetch — encapsulates state + effect | Ideal |
| Third-party | React Query, SWR, RTK Query | Complex apps |
4. URL State and Web Storage
4.1 Navigation Flow with React Router
import { Routes, Route } from "react-router-dom";
export default function App() {
return (
<Routes>
<Route path="/" element={<h1>Welcome</h1>} />
<Route path="/:category" element={<Products />} />
<Route path="/:category/:id" element={<Detail />} />
<Route path="/cart" element={<Cart />} />
</Routes>
);
}
4.2 Reading URL Parameters with useParams
import { useParams } from "react-router-dom";
export default function Products() {
const { category } = useParams();
const { data: products } = useFetch(`products?category=${category}`);
}
4.3 Programmatic Navigation with useNavigate
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
<button onClick={() => navigate("/cart")}>Go to Cart</button>
4.4 Persistence with localStorage
const [cart, setCart] = useState(() => {
try {
return JSON.parse(localStorage.getItem("cart")) ?? [];
} catch {
return [];
}
});
useEffect(() => {
localStorage.setItem("cart", JSON.stringify(cart));
}, [cart]);
4.5 Web Storage Comparison
| Technology | Persistence | Size | Sync? | Security |
|---|---|---|---|---|
| localStorage | Permanent | ~5-10 MB | Yes | Not for sensitive data |
| sessionStorage | Tab duration | ~5 MB | Yes | |
| Cookies | Configurable | ~4 KB | Yes | Can be httpOnly |
| IndexedDB | Permanent | Large | No (Async) | Comparable to localStorage |
5. Shared, Derived, and Immutable State
5.1 Lifting State — Where to Place State?
flowchart TD
A[New state needed] --> B{Only this component uses it?}
B -->|Yes| C[Declare in this component]
B -->|No| D{Children use it?}
D -->|Yes| E[Pass via props]
D -->|No| F{Non-child components use it?}
F -->|Yes| G[Lift state up to common parent]
G --> H{Prop drilling too deep?}
H -->|Yes| I[Context or Redux]
H -->|No| E
5.2 Immutability — Why?
React compares memory references to detect changes. If you mutate state, React sees no change → no re-render.
// BAD — direct mutation
state.user.name = "Alice"; // React doesn't re-render!
// GOOD — new object
setState({ ...state, user: { ...state.user, name: "Alice" } });
5.3 Immutable Update Techniques
// Object spread — modify a property
const newUser = { ...user, role: "admin" };
// Array spread — add an item
const newItems = [...items, { id, sku, quantity: 1 }];
// Array.map — modify an item
const updated = items.map((i) =>
i.sku === sku ? { ...i, quantity: i.quantity + 1 } : i
);
// Array.filter — remove an item
const removed = items.filter((i) => i.sku !== sku);
5.4 addToCart — Complete Immutable Example
const [cart, setCart] = useState([]);
function addToCart(id, sku) {
setCart((items) => {
const itemInCart = items.find((i) => i.sku === sku);
if (itemInCart) {
return items.map((i) =>
i.sku === sku ? { ...i, quantity: i.quantity + 1 } : i
);
} else {
return [...items, { id, sku, quantity: 1 }];
}
});
}
function updateQuantity(sku, quantity) {
setCart((items) =>
quantity === 0
? items.filter((i) => i.sku !== sku)
: items.map((i) => (i.sku === sku ? { ...i, quantity } : i))
);
}
Function form of setState: required when new state depends on previous state, since
setStateis async and batched.
5.5 Derived State — Calculate on the Fly
// Don't store numItemsInCart in state
// Derive it from existing cart
const numItemsInCart = cart.reduce((total, item) => total + item.quantity, 0);
const cartHeader = numItemsInCart === 0
? "Your cart is empty"
: `Cart (${numItemsInCart} items)`;
6. Form State and Validation
6.1 Status Enum — Avoid Multiple Booleans
// Anti-pattern — 3 overlapping booleans
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
// Recommended pattern — single state enum
const STATUS = {
IDLE: "IDLE",
SUBMITTING: "SUBMITTING",
SUBMITTED: "SUBMITTED",
COMPLETED: "COMPLETED",
};
const [status, setStatus] = useState(STATUS.IDLE);
6.2 Derived Validation (Calculated at Each Render)
// Errors are DERIVED, not stored in state
function getErrors(address) {
const result = {};
if (!address.city) result.city = "City is required";
if (!address.country) result.country = "Country is required";
return result;
}
const errors = getErrors(address);
const isValid = Object.keys(errors).length === 0;
6.3 Complete Controlled Form
export default function Checkout() {
const [address, setAddress] = useState({ city: "", country: "" });
const [touched, setTouched] = useState({});
const [status, setStatus] = useState(STATUS.IDLE);
function handleChange(event) {
setAddress((curAddress) => ({
...curAddress,
[event.target.id]: event.target.value,
}));
}
function handleBlur(event) {
setTouched((cur) => ({ ...cur, [event.target.id]: true }));
}
async function handleSubmit(event) {
event.preventDefault();
setStatus(STATUS.SUBMITTING);
if (isValid) {
try {
await saveShippingAddress(address);
setStatus(STATUS.COMPLETED);
} catch (e) {
throw e;
}
} else {
setStatus(STATUS.SUBMITTED);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="city">City</label>
<input
id="city"
value={address.city}
onChange={handleChange}
onBlur={handleBlur}
/>
{(touched.city || status === STATUS.SUBMITTED) && errors.city && (
<p role="alert">{errors.city}</p>
)}
</div>
<button disabled={status === STATUS.SUBMITTING}>
{status === STATUS.SUBMITTING ? "Saving..." : "Submit"}
</button>
</form>
);
}
6.4 Controlled vs. Uncontrolled
| Controlled | Uncontrolled (ref) | |
|---|---|---|
| Source of truth | React State | HTML DOM |
| Instant validation | Yes | No (only onSubmit) |
| Conditional disable Submit | Yes | Difficult |
| Forced input format | Yes | No |
| Dynamic inputs | Yes | Difficult |
| Main use case | 95% of cases | Third-party lib integration |
7. State via Refs
7.1 When to Use useRef
import { useRef, useEffect } from "react";
// 1. DOM reference — auto focus
function SearchInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} type="text" />;
}
// 2. Store previous value
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; });
return ref.current; // returns value from PREVIOUS render
}
8. Complex State with useReducer
8.1 How useReducer Works
flowchart LR
A[Component] -->|dispatch action| B[Reducer Function]
B -->|returns new state| C[Updated State]
C -->|re-render| A
8.2 Cart Reducer
// src/cartReducer.js
export default function cartReducer(cart, action) {
switch (action.type) {
case "empty":
return [];
case "updateQuantity": {
const { quantity, sku } = action;
return quantity === 0
? cart.filter((i) => i.sku !== sku)
: cart.map((i) => (i.sku === sku ? { ...i, quantity } : i));
}
case "add": {
const { id, sku } = action;
const itemInCart = cart.find((i) => i.sku === sku);
if (itemInCart) {
return cart.map((i) =>
i.sku === sku ? { ...i, quantity: i.quantity + 1 } : i
);
} else {
return [...cart, { id, sku, quantity: 1 }];
}
}
default:
throw new Error("Unhandled action: " + action.type);
}
}
8.3 Consuming the Reducer
import { useReducer } from "react";
import cartReducer from "./cartReducer";
let initialCart;
try {
initialCart = JSON.parse(localStorage.getItem("cart")) ?? [];
} catch {
initialCart = [];
}
export default function App() {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
return (
<Detail
onAddToCart={(id, sku) => dispatch({ type: "add", id, sku })}
/>
);
}
8.4 Testability
import cartReducer from "./cartReducer";
test("add new item to cart", () => {
const initialCart = [];
const action = { type: "add", id: 1, sku: 101 };
const newCart = cartReducer(initialCart, action);
expect(newCart).toEqual([{ id: 1, sku: 101, quantity: 1 }]);
});
8.5 useState vs. useReducer
| Criteria | useState | useReducer |
|---|---|---|
| Simplicity | Simple | Verbose |
| Complex transitions | Difficult | Ideal |
| Testability | Tied to component | Isolated (pure function) |
| Reusability | No | Multiple components |
| When to use | Default recommendation | Complex state, multiple transitions |
9. Sharing State via Context API
9.1 Context API Flow
flowchart TD
A[index.js] --> B["CartProvider"]
B --> C["ErrorBoundary"]
C --> D["App"]
D --> E[Header]
D --> F[Cart]
D --> G[Checkout]
J["CartContext.Provider\n{cart, dispatch}"] -.->|useCart hook| F
J -.->|useCart hook| G
9.2 Creating Context and Provider
// src/cartContext.js
import React, { useReducer, useEffect, useContext } from "react";
import cartReducer from "./cartReducer";
const CartContext = React.createContext(null);
let initialCart;
try {
initialCart = JSON.parse(localStorage.getItem("cart")) ?? [];
} catch {
initialCart = [];
}
export function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
useEffect(() => {
localStorage.setItem("cart", JSON.stringify(cart));
}, [cart]);
return (
<CartContext.Provider value={{ cart, dispatch }}>
{children}
</CartContext.Provider>
);
}
// Custom hook — encapsulates useContext
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used inside CartProvider");
return context;
}
9.3 Using in Components
// In any component in the tree
import { useCart } from "./cartContext";
export default function Cart() {
const { cart, dispatch } = useCart();
return (
<ul>
{cart.map((item) => (
<li key={item.sku}>
{item.sku} — Qty: {item.quantity}
<button onClick={() => dispatch({ type: "updateQuantity", sku: item.sku, quantity: item.quantity - 1 })}>
-
</button>
</li>
))}
</ul>
);
}
10. Quick Reference — When to Use What
| Scenario | Solution |
|---|---|
| Simple component state | useState |
| Fetch data from API | useEffect + custom hook |
| Share between siblings | Lift state to common parent |
| Persist across reloads | localStorage + useState |
| URL navigation state | React Router useParams |
| Complex state transitions | useReducer |
| Global app state | Context API + useReducer |
| Calculated value | Derived state (no storage) |
| DOM access | useRef |
| Advanced server cache | React Query / SWR |
useFetchAll — Parallel Requests with useRef
export default function useFetchAll(urls) {
const prevUrls = useRef([]);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (areEqual(prevUrls.current, urls)) return;
prevUrls.current = urls;
Promise.all(
urls.map((url) =>
fetch(process.env.REACT_APP_API_BASE_URL + url).then((r) => {
if (r.ok) return r.json();
throw r;
})
)
)
.then(setData)
.catch((e) => setError(e))
.finally(() => setLoading(false));
}, [urls]);
return { data, loading, error };
}
function areEqual(a, b) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
Search Terms
managing · state · react · typescript · frontend · development · api · context · derived · flow · immutable · usereducer · controlled · fetching · form · local · manage · navigation · reducer · remote · storage · url · useref · usestate