Intermediate

Managing State in React 18

Local, remote, URL and web-storage state, derived state, forms, refs, useReducer and Context.

Demo application: Carved Rock Fitness — online shoe store


Table of Contents

  1. Course Overview
  2. Deciding How to Manage State
  3. Local State and Remote State (useState / useEffect)
  4. URL State and Web Storage (React Router / localStorage)
  5. Shared, Derived, and Immutable State
  6. Form State and Validation
  7. State via Refs (useRef)
  8. Complex State with useReducer
  9. Sharing State via Context API
  10. 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

#ApproachTypical Usage
1URLCurrent page, filters, pagination, sorting
2Web Storage (localStorage, sessionStorage)Persistence across reloads
3Local Component State (useState)Component-specific data
4Lifted StateSharing between sibling components via common parent
5Derived StateCalculated from existing state, not stored
6Refs (useRef)DOM, unrendered values, timers
7ContextGlobal data shared in component tree
8Third-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

TypeMutable?Examples
Boolean, String, NumberNo (primitives)true, "hello", 42
Object, ArrayYes{}, []
Map, SetYesKey-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:

  1. Only in function components
  2. Name must start with use
  3. 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

ApproachDescriptionRecommended?
Inlinefetch() directly in componentNo — hard to test/reuse
CentralizedFunctions in /services/ imported in componentGood for starting
Custom HookuseFetch — encapsulates state + effectIdeal
Third-partyReact Query, SWR, RTK QueryComplex 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

TechnologyPersistenceSizeSync?Security
localStoragePermanent~5-10 MBYesNot for sensitive data
sessionStorageTab duration~5 MBYes
CookiesConfigurable~4 KBYesCan be httpOnly
IndexedDBPermanentLargeNo (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 setState is 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

ControlledUncontrolled (ref)
Source of truthReact StateHTML DOM
Instant validationYesNo (only onSubmit)
Conditional disable SubmitYesDifficult
Forced input formatYesNo
Dynamic inputsYesDifficult
Main use case95% of casesThird-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

CriteriauseStateuseReducer
SimplicitySimpleVerbose
Complex transitionsDifficultIdeal
TestabilityTied to componentIsolated (pure function)
ReusabilityNoMultiple components
When to useDefault recommendationComplex 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

ScenarioSolution
Simple component stateuseState
Fetch data from APIuseEffect + custom hook
Share between siblingsLift state to common parent
Persist across reloadslocalStorage + useState
URL navigation stateReact Router useParams
Complex state transitionsuseReducer
Global app stateContext API + useReducer
Calculated valueDerived state (no storage)
DOM accessuseRef
Advanced server cacheReact 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

Interested in this course?

Contact us to book it or get a custom training plan for your team.