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

2.3.e — Derived State

In one sentence: Derived state is any value you can compute from existing state or props during render -- it should never be stored in its own useState because that creates synchronization bugs and unnecessary complexity.

Navigation: ← 2.3.d — Batching State Updates · Next → 2.3.f — Practical Build


Table of Contents

  1. What Is Derived State
  2. The Problem: Duplicating State
  3. Identifying Derived State Opportunities
  4. Computing Values During Render
  5. Common Derived State Examples
  6. When to Use useMemo
  7. The useEffect Anti-Pattern
  8. Single Source of Truth
  9. State Normalization Patterns
  10. Computed Properties Pattern
  11. Refactoring: Removing Unnecessary State
  12. Performance Considerations
  13. Real-World Refactoring Examples
  14. Decision Framework
  15. Key Takeaways
  16. Explain-It Challenge

1. What Is Derived State

Derived state is a value that can be calculated from other state or props. It's not independent data -- it's a transformation or computation of data you already have.

Source state:          Derived values:
+------------------+   +---------------------------+
| items: [...]     |-->| itemCount: items.length    |
|                  |-->| totalPrice: sum of prices  |
|                  |-->| hasItems: items.length > 0 |
+------------------+   +---------------------------+

| filter: "active" |-->| filteredItems: items where |
|                  |   |   status === filter        |
+------------------+   +---------------------------+

The core principle: if you can compute it, don't store it. Storing derived values in state creates two sources of truth for the same data, and they will eventually fall out of sync.

The Simplest Example

// BAD: Storing derived state
function UserProfile({ firstName, lastName }) {
  const [fullName, setFullName] = useState(`${firstName} ${lastName}`);
  // fullName is derived from firstName + lastName
  // If firstName changes via props, fullName is now WRONG
  // You'd need useEffect to keep them in sync -- fragile!
  return <h1>{fullName}</h1>;
}

// GOOD: Compute during render
function UserProfile({ firstName, lastName }) {
  const fullName = `${firstName} ${lastName}`;
  // Always correct, no sync issues
  return <h1>{fullName}</h1>;
}

2. The Problem: Duplicating State

When you store derived values in state, you create a synchronization problem. Every time the source data changes, you must also update the derived data. Forget once, and you have a bug.

The Bug Factory

function ProductList() {
  const [products, setProducts] = useState([
    { id: 1, name: "Shirt", price: 25, quantity: 2 },
    { id: 2, name: "Pants", price: 50, quantity: 1 },
    { id: 3, name: "Hat", price: 15, quantity: 3 },
  ]);

  // BAD: Derived values stored in state
  const [total, setTotal] = useState(145);      // 25*2 + 50*1 + 15*3
  const [itemCount, setItemCount] = useState(6); // 2 + 1 + 3
  const [cheapest, setCheapest] = useState(15);

  function removeProduct(id) {
    setProducts(prev => prev.filter(p => p.id !== id));
    // OOPS! Forgot to update total, itemCount, and cheapest!
    // Now they're stale. The UI shows wrong numbers.

    // To fix, you'd need:
    // setTotal(newProducts.reduce((sum, p) => sum + p.price * p.quantity, 0));
    // setItemCount(newProducts.reduce((sum, p) => sum + p.quantity, 0));
    // setCheapest(Math.min(...newProducts.map(p => p.price)));
    // That's 4 state updates for one logical operation. Error-prone.
  }

  function updateQuantity(id, qty) {
    setProducts(prev => prev.map(p => p.id === id ? { ...p, quantity: qty } : p));
    // Again must update total, itemCount, cheapest manually
    // Every function that modifies products must also update 3 other state values
  }
}

The Fix

function ProductList() {
  const [products, setProducts] = useState([
    { id: 1, name: "Shirt", price: 25, quantity: 2 },
    { id: 2, name: "Pants", price: 50, quantity: 1 },
    { id: 3, name: "Hat", price: 15, quantity: 3 },
  ]);

  // GOOD: Derived during render -- always in sync
  const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
  const itemCount = products.reduce((sum, p) => sum + p.quantity, 0);
  const cheapest = products.length > 0
    ? Math.min(...products.map(p => p.price))
    : 0;

  function removeProduct(id) {
    setProducts(prev => prev.filter(p => p.id !== id));
    // total, itemCount, cheapest automatically recalculate on re-render
    // No manual sync needed. Impossible to forget.
  }

  function updateQuantity(id, qty) {
    setProducts(prev => prev.map(p => p.id === id ? { ...p, quantity: qty } : p));
    // Everything stays in sync automatically
  }
}

The Root Cause

Duplicated state creates this dependency graph:

  products -----> total
      |--------> itemCount
      |--------> cheapest

Every change to 'products' requires manual updates to 3 other states.
Miss one? Bug.
Add a new derived value? Another manual sync point.

Derived values eliminate the graph:

  products (only source of truth)
      |
      +--> total = computed
      +--> itemCount = computed
      +--> cheapest = computed

Change products, everything updates automatically.

3. Identifying Derived State Opportunities

Derived state hides in plain sight. Here's how to spot it.

The Smell Test

If you find yourself writing a useEffect that watches one state and updates another, you probably have derived state:

// SMELL: useEffect that syncs state
useEffect(() => {
  setFilteredItems(items.filter(i => i.name.includes(searchQuery)));
}, [items, searchQuery]);
// filteredItems is derived from items + searchQuery!

If you find yourself updating multiple state variables in every event handler, some of them are probably derived:

// SMELL: Multiple state updates where some follow from others
function handleAddItem(item) {
  const newItems = [...items, item];
  setItems(newItems);
  setItemCount(newItems.length);        // Derived!
  setHasItems(true);                     // Derived!
  setTotal(prev => prev + item.price);  // Derived!
}

Identification Checklist

For each piece of state, ask:

QuestionIf Yes
Can I compute this from other state?Derived -- remove it
Can I compute this from props?Derived -- remove it
Can I compute this from state + props?Derived -- remove it
Does this change ONLY when another state changes?Likely derived
Do I always update this alongside another state?Likely derived
Is this a transformation (filter, sort, map) of other state?Derived
Is this a summary (count, sum, min, max) of other state?Derived
Is this a boolean check on other state?Derived

Common Patterns That Signal Derived State

// Pattern: Filtered version of a list
const [items, setItems] = useState([...]);
const [filteredItems, setFilteredItems] = useState([]); // DERIVED

// Pattern: Count of items
const [items, setItems] = useState([...]);
const [count, setCount] = useState(0); // DERIVED

// Pattern: Boolean check
const [items, setItems] = useState([...]);
const [isEmpty, setIsEmpty] = useState(true); // DERIVED

// Pattern: Computed aggregate
const [items, setItems] = useState([...]);
const [total, setTotal] = useState(0); // DERIVED

// Pattern: Selection status
const [items, setItems] = useState([...]);
const [selectedItem, setSelectedItem] = useState(null); // Might be derived if you store selectedId instead

// Pattern: Form validation
const [email, setEmail] = useState("");
const [isEmailValid, setIsEmailValid] = useState(false); // DERIVED

4. Computing Values During Render

The simplest and most common approach: compute derived values as regular variables in the component body.

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"

  // Derived values -- computed every render
  const filteredTodos = todos.filter(todo => {
    if (filter === "active") return !todo.done;
    if (filter === "completed") return todo.done;
    return true;
  });

  const activeTodoCount = todos.filter(t => !t.done).length;
  const completedTodoCount = todos.filter(t => t.done).length;
  const allDone = todos.length > 0 && todos.every(t => t.done);
  const progress = todos.length > 0
    ? Math.round((completedTodoCount / todos.length) * 100)
    : 0;

  return (
    <div>
      <h1>Todos ({activeTodoCount} remaining)</h1>
      <progress value={progress} max={100} />
      <FilterBar current={filter} onChange={setFilter} />
      <TodoList todos={filteredTodos} />
      {allDone && <p>All done!</p>}
    </div>
  );
}

Every time todos or filter changes, the component re-renders and all derived values are automatically recalculated. No synchronization code. No bugs.

Performance: Is Recomputing Every Render Okay?

For most cases, yes. JavaScript is fast. Consider what these computations actually cost:

Operation                          Time for 100 items    Time for 1000 items
Array.filter()                     ~0.01ms               ~0.1ms
Array.reduce() (sum)               ~0.005ms              ~0.05ms
Array.map()                        ~0.01ms               ~0.1ms
Array.every() / Array.some()       ~0.005ms              ~0.05ms
String concatenation               ~0.001ms              ~0.001ms

These times are tiny compared to a full component render (typically 0.5-5ms). The overhead of computing derived values is negligible for most applications.

If you have thousands of items or expensive computations, use useMemo (covered below).


5. Common Derived State Examples

Example 1: Full Name from Parts

function UserCard({ firstName, lastName, title }) {
  // Derived
  const fullName = `${firstName} ${lastName}`;
  const displayName = title ? `${title} ${fullName}` : fullName;
  const initials = `${firstName[0]}${lastName[0]}`.toUpperCase();

  return (
    <div>
      <div className="avatar">{initials}</div>
      <h2>{displayName}</h2>
    </div>
  );
}

Example 2: Filtered and Sorted List

function ContactList() {
  const [contacts, setContacts] = useState([...]);
  const [searchQuery, setSearchQuery] = useState("");
  const [sortField, setSortField] = useState("name");
  const [sortDirection, setSortDirection] = useState("asc");

  // Derived: filter then sort
  const filteredContacts = contacts.filter(contact =>
    contact.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
    contact.email.toLowerCase().includes(searchQuery.toLowerCase())
  );

  const sortedContacts = [...filteredContacts].sort((a, b) => {
    const aVal = a[sortField];
    const bVal = b[sortField];
    const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
    return sortDirection === "asc" ? comparison : -comparison;
  });

  // Derived: stats
  const resultCount = sortedContacts.length;
  const isFiltered = searchQuery.trim() !== "";
  const noResults = isFiltered && resultCount === 0;

  return (
    <div>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="Search contacts..."
      />
      <p>
        {isFiltered
          ? `${resultCount} of ${contacts.length} contacts`
          : `${contacts.length} contacts`
        }
      </p>
      {noResults && <p>No contacts match your search.</p>}
      <ul>
        {sortedContacts.map(c => (
          <li key={c.id}>{c.name} - {c.email}</li>
        ))}
      </ul>
    </div>
  );
}

Example 3: Shopping Cart Summary

function Cart() {
  const [items, setItems] = useState([]);
  const [couponCode, setCouponCode] = useState("");

  // Derived
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  const discount = couponCode === "SAVE10" ? subtotal * 0.10 : 0;
  const tax = (subtotal - discount) * 0.08;
  const total = subtotal - discount + tax;
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
  const isEmpty = items.length === 0;
  const freeShipping = subtotal >= 50;
  const shippingCost = freeShipping ? 0 : 5.99;
  const grandTotal = total + shippingCost;

  return (
    <div>
      {isEmpty ? (
        <p>Your cart is empty</p>
      ) : (
        <>
          <ul>
            {items.map(item => (
              <li key={item.id}>
                {item.name} x{item.quantity} = ${(item.price * item.quantity).toFixed(2)}
              </li>
            ))}
          </ul>
          <div>
            <p>Items: {itemCount}</p>
            <p>Subtotal: ${subtotal.toFixed(2)}</p>
            {discount > 0 && <p>Discount: -${discount.toFixed(2)}</p>}
            <p>Tax: ${tax.toFixed(2)}</p>
            <p>Shipping: {freeShipping ? "FREE" : `$${shippingCost.toFixed(2)}`}</p>
            {!freeShipping && (
              <p>Add ${(50 - subtotal).toFixed(2)} more for free shipping</p>
            )}
            <p>Total: ${grandTotal.toFixed(2)}</p>
          </div>
        </>
      )}
    </div>
  );
}

Example 4: Form Validation

function SignupForm() {
  const [form, setForm] = useState({
    username: "",
    email: "",
    password: "",
    confirmPassword: "",
  });

  // Derived: validation results
  const errors = {
    username: form.username.length > 0 && form.username.length < 3
      ? "Username must be at least 3 characters"
      : "",
    email: form.email.length > 0 && !form.email.includes("@")
      ? "Invalid email address"
      : "",
    password: form.password.length > 0 && form.password.length < 8
      ? "Password must be at least 8 characters"
      : "",
    confirmPassword: form.confirmPassword.length > 0 && form.confirmPassword !== form.password
      ? "Passwords do not match"
      : "",
  };

  // Derived: overall form status
  const hasErrors = Object.values(errors).some(e => e !== "");
  const allFieldsFilled = Object.values(form).every(v => v.length > 0);
  const isValid = allFieldsFilled && !hasErrors;
  const passwordStrength = getPasswordStrength(form.password);

  function handleChange(field, value) {
    setForm(prev => ({ ...prev, [field]: value }));
    // No need to call setErrors, setIsValid, etc.
    // Everything recomputes automatically on the next render.
  }

  return (
    <form>
      <div>
        <input
          value={form.username}
          onChange={e => handleChange("username", e.target.value)}
          placeholder="Username"
        />
        {errors.username && <span className="error">{errors.username}</span>}
      </div>
      <div>
        <input
          value={form.email}
          onChange={e => handleChange("email", e.target.value)}
          placeholder="Email"
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      <div>
        <input
          type="password"
          value={form.password}
          onChange={e => handleChange("password", e.target.value)}
          placeholder="Password"
        />
        {errors.password && <span className="error">{errors.password}</span>}
        <meter value={passwordStrength} max={4} />
      </div>
      <div>
        <input
          type="password"
          value={form.confirmPassword}
          onChange={e => handleChange("confirmPassword", e.target.value)}
          placeholder="Confirm Password"
        />
        {errors.confirmPassword && <span className="error">{errors.confirmPassword}</span>}
      </div>
      <button disabled={!isValid}>Sign Up</button>
    </form>
  );
}

function getPasswordStrength(password) {
  let score = 0;
  if (password.length >= 8) score++;
  if (/[A-Z]/.test(password)) score++;
  if (/[0-9]/.test(password)) score++;
  if (/[^A-Za-z0-9]/.test(password)) score++;
  return score;
}

Example 5: Temperature Converter

function TemperatureConverter() {
  const [celsius, setCelsius] = useState(0);

  // All derived from celsius
  const fahrenheit = (celsius * 9 / 5) + 32;
  const kelvin = celsius + 273.15;
  const description = celsius < 0
    ? "Freezing"
    : celsius < 15
    ? "Cold"
    : celsius < 25
    ? "Comfortable"
    : celsius < 35
    ? "Warm"
    : "Hot";

  return (
    <div>
      <label>
        Celsius:
        <input
          type="number"
          value={celsius}
          onChange={e => setCelsius(Number(e.target.value))}
        />
      </label>
      <p>Fahrenheit: {fahrenheit.toFixed(1)}</p>
      <p>Kelvin: {kelvin.toFixed(1)}</p>
      <p>Feels: {description}</p>
    </div>
  );
}

6. When to Use useMemo

useMemo caches a computed value so it's not recalculated unless its dependencies change. Use it when a derived computation is genuinely expensive.

Without useMemo

function SearchResults({ items, query }) {
  // Runs on every render, even if items and query haven't changed
  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );
  return <ul>{filtered.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}

With useMemo

function SearchResults({ items, query }) {
  // Only recalculates when items or query changes
  const filtered = useMemo(() =>
    items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    ),
    [items, query]
  );
  return <ul>{filtered.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}

When useMemo Is Worth It

ScenariouseMemo?Why
Filtering 50 itemsNoArray.filter on 50 items is ~0.01ms
Filtering 50,000 itemsYesCould take 5-10ms per render
Sorting 100 itemsNoFast enough
Sorting 10,000 itemsPossiblyDepends on comparator complexity
Complex regex on short stringNoMilliseconds at most
Parsing a large JSON blobYesCould be expensive
Simple arithmetic (sum, count)NoAlways fast
Generating a large data structureYesObject creation can be expensive at scale
Computing a value used in many child propsPossiblyIf it prevents unnecessary child re-renders

The Rule of Thumb

  1. Start without useMemo (just compute during render)
  2. Measure if you suspect a performance issue (React DevTools Profiler)
  3. Add useMemo only if the computation is measurably slow
// DON'T do this -- premature optimization
const doubled = useMemo(() => count * 2, [count]);
// Multiplying two numbers doesn't need memoization

// DO this when you have evidence of slowness
const sortedItems = useMemo(() => {
  console.time("sort");
  const result = [...items].sort(complexComparator);
  console.timeEnd("sort"); // Check if this is actually slow
  return result;
}, [items]);

useMemo Syntax

const memoizedValue = useMemo(
  () => computeExpensiveValue(dep1, dep2), // Factory function
  [dep1, dep2] // Dependency array
);

// The factory runs:
// - On first render
// - When any dependency changes
// Otherwise, the cached value is returned

useMemo Does NOT Guarantee No Recomputation

React may throw away cached values to free memory. useMemo is a performance hint, not a semantic guarantee. Your code must work correctly without it -- it should just be slower.


7. The useEffect Anti-Pattern

The most common mistake with derived state is using useEffect to synchronize it. This is almost always wrong.

The Anti-Pattern

// BAD: Syncing derived state with useEffect
function ProductList() {
  const [products, setProducts] = useState([...]);
  const [filteredProducts, setFilteredProducts] = useState([]);

  useEffect(() => {
    setFilteredProducts(products.filter(p => p.inStock));
  }, [products]);

  return <List items={filteredProducts} />;
}

Why This Is Bad

  1. Extra render: The component renders with stale filteredProducts, then useEffect fires, updates state, causing a second render with the correct data. The user briefly sees wrong data.

  2. Unnecessary complexity: You have state, an effect, and a dependency array for something that could be one line.

  3. Potential for stale UI: On the first render after products changes, the component uses the OLD filteredProducts. The effect hasn't run yet.

Timeline with useEffect:
  products changes
    |
    v
  Render 1: filteredProducts = [old data]  <-- WRONG!
    |
    v
  useEffect fires: setFilteredProducts([new data])
    |
    v
  Render 2: filteredProducts = [new data]  <-- correct, but we wasted Render 1

Timeline with derived value:
  products changes
    |
    v
  Render 1: filteredProducts computed = [new data]  <-- correct immediately

The Fix

// GOOD: Compute during render
function ProductList() {
  const [products, setProducts] = useState([...]);

  const filteredProducts = products.filter(p => p.inStock);

  return <List items={filteredProducts} />;
}

More Examples of the Anti-Pattern

// BAD: Each of these should be a derived value, not useEffect + state

// Derived total
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
// FIX: const total = items.reduce((sum, i) => sum + i.price, 0);

// Derived validation
const [email, setEmail] = useState("");
const [isValid, setIsValid] = useState(false);
useEffect(() => {
  setIsValid(email.includes("@") && email.includes("."));
}, [email]);
// FIX: const isValid = email.includes("@") && email.includes(".");

// Derived display text
const [count, setCount] = useState(0);
const [label, setLabel] = useState("0 items");
useEffect(() => {
  setLabel(`${count} item${count !== 1 ? "s" : ""}`);
}, [count]);
// FIX: const label = `${count} item${count !== 1 ? "s" : ""}`;

// Derived selected item
const [items, setItems] = useState([...]);
const [selectedId, setSelectedId] = useState(null);
const [selectedItem, setSelectedItem] = useState(null);
useEffect(() => {
  setSelectedItem(items.find(i => i.id === selectedId));
}, [items, selectedId]);
// FIX: const selectedItem = items.find(i => i.id === selectedId);

When useEffect IS Appropriate for State

useEffect that sets state is appropriate when:

  1. Fetching data from an API:
useEffect(() => {
  fetch("/api/products").then(r => r.json()).then(setProducts);
}, []);
// This is an external side effect, not a derivation
  1. Subscribing to external data sources:
useEffect(() => {
  const unsubscribe = websocket.subscribe(data => setMessages(prev => [...prev, data]));
  return unsubscribe;
}, []);
  1. Syncing with browser APIs:
useEffect(() => {
  function handleResize() {
    setWindowWidth(window.innerWidth);
  }
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

The pattern: if the data comes from outside React (API, browser, WebSocket, localStorage), useEffect + setState is correct. If the data comes from inside React (other state, props), compute it during render.


8. Single Source of Truth

The principle: for every piece of data in your application, there should be exactly one authoritative source. Everything else is derived from that source.

What It Means

Data: "How many items are in the cart?"

BAD (two sources):
  const [items, setItems] = useState([...]);
  const [itemCount, setItemCount] = useState(3);
  // Which one is "correct"? They might disagree.

GOOD (one source):
  const [items, setItems] = useState([...]);
  const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
  // items is the single source. itemCount always agrees.

Applying the Principle

Ask: "If I had to pick ONE variable as the ground truth, which would it be?"

Everything else should derive from that one variable.

// Ground truth: items array
const [items, setItems] = useState([]);

// All of these derive from items:
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const isEmpty = items.length === 0;
const hasExpensiveItem = items.some(i => i.price > 100);
const cheapestItem = items.length > 0
  ? items.reduce((min, i) => i.price < min.price ? i : min)
  : null;
const averagePrice = items.length > 0
  ? items.reduce((sum, i) => sum + i.price, 0) / items.length
  : 0;

// Change items once, everything updates.
function clearCart() {
  setItems([]);
  // total, count, isEmpty, etc. all automatically become correct
  // No manual syncing needed
}

Selecting from a List

A common case: you have a list of items and one is "selected."

// BAD: Storing the full selected item separately
const [users, setUsers] = useState([...]);
const [selectedUser, setSelectedUser] = useState(null);
// If a user's name gets updated in the users array,
// selectedUser still has the old name!

// GOOD: Store only the ID, derive the full object
const [users, setUsers] = useState([...]);
const [selectedUserId, setSelectedUserId] = useState(null);
const selectedUser = users.find(u => u.id === selectedUserId) || null;
// If a user's name changes in the array, selectedUser reflects it immediately

9. State Normalization Patterns

Normalization is the practice of structuring your state to avoid duplication and make updates straightforward.

Unnormalized (Nested)

const [departments, setDepartments] = useState([
  {
    id: "d1",
    name: "Engineering",
    lead: { id: "u1", name: "Alice", email: "alice@co.com" },
    members: [
      { id: "u2", name: "Bob", email: "bob@co.com" },
      { id: "u3", name: "Charlie", email: "charlie@co.com" },
    ],
  },
  {
    id: "d2",
    name: "Design",
    lead: { id: "u3", name: "Charlie", email: "charlie@co.com" }, // Duplicated!
    members: [
      { id: "u4", name: "Diana", email: "diana@co.com" },
    ],
  },
]);

// Charlie appears twice. If Charlie's email changes,
// you must update it in TWO places.

Normalized (Flat)

const [users, setUsers] = useState({
  u1: { id: "u1", name: "Alice", email: "alice@co.com" },
  u2: { id: "u2", name: "Bob", email: "bob@co.com" },
  u3: { id: "u3", name: "Charlie", email: "charlie@co.com" },
  u4: { id: "u4", name: "Diana", email: "diana@co.com" },
});

const [departments, setDepartments] = useState({
  d1: { id: "d1", name: "Engineering", leadId: "u1", memberIds: ["u2", "u3"] },
  d2: { id: "d2", name: "Design", leadId: "u3", memberIds: ["u4"] },
});

// Charlie exists in ONE place. Update once, correct everywhere.
// Derived: get full department data
function getDepartmentWithMembers(deptId) {
  const dept = departments[deptId];
  return {
    ...dept,
    lead: users[dept.leadId],
    members: dept.memberIds.map(id => users[id]),
  };
}

Benefits of Normalization

AspectUnnormalizedNormalized
Update a user's emailFind all copies, update eachUpdate in ONE place
Check if entity existsTraverse nested structuresusers[id] !== undefined
Avoid data duplicationImpossibleBy design
Memory usageHigher (duplicated data)Lower
Read a single entityMay need to searchDirect lookup: O(1)
Complexity of updatesHigh (deep spreads)Low (flat spreads)

When to Normalize

  • Data has entities that can be referenced from multiple places
  • Same entity appears in multiple lists or nested structures
  • Frequent updates to individual entities
  • Data resembles a database (relational)

When NOT to Normalize

  • Simple flat lists with no cross-references
  • Data is read-only (no updates after initial load)
  • Small data sets where duplication doesn't matter
  • Component-local state with no shared references

10. Computed Properties Pattern

A pattern for organizing derived values in custom hooks.

The Pattern

function useTaskManager() {
  const [tasks, setTasks] = useState([]);
  const [filter, setFilter] = useState("all");
  const [sortBy, setSortBy] = useState("createdAt");

  // Derived values organized in a "computed" object
  const computed = {
    filteredTasks: tasks.filter(task => {
      if (filter === "pending") return !task.done;
      if (filter === "completed") return task.done;
      return true;
    }),

    get sortedTasks() {
      return [...this.filteredTasks].sort((a, b) => {
        if (sortBy === "createdAt") return b.createdAt - a.createdAt;
        if (sortBy === "priority") return b.priority - a.priority;
        return a.title.localeCompare(b.title);
      });
    },

    stats: {
      total: tasks.length,
      pending: tasks.filter(t => !t.done).length,
      completed: tasks.filter(t => t.done).length,
      get progress() {
        return tasks.length > 0
          ? Math.round((this.completed / tasks.length) * 100)
          : 0;
      },
    },
  };

  // Actions
  function addTask(title, priority = 1) {
    setTasks(prev => [...prev, {
      id: Date.now().toString(),
      title,
      priority,
      done: false,
      createdAt: Date.now(),
    }]);
  }

  function toggleTask(id) {
    setTasks(prev => prev.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }

  function removeTask(id) {
    setTasks(prev => prev.filter(t => t.id !== id));
  }

  return {
    tasks: computed.sortedTasks,
    stats: computed.stats,
    filter,
    setFilter,
    sortBy,
    setSortBy,
    addTask,
    toggleTask,
    removeTask,
  };
}

Simpler Approach: Just Variables

For most cases, plain variables are clearer than a computed object:

function useTaskManager() {
  const [tasks, setTasks] = useState([]);
  const [filter, setFilter] = useState("all");

  const filteredTasks = tasks.filter(task => {
    if (filter === "pending") return !task.done;
    if (filter === "completed") return task.done;
    return true;
  });

  const totalCount = tasks.length;
  const pendingCount = tasks.filter(t => !t.done).length;
  const completedCount = tasks.filter(t => t.done).length;

  // ... actions ...

  return { filteredTasks, totalCount, pendingCount, completedCount, /* ... */ };
}

11. Refactoring: Removing Unnecessary State

Step-by-Step Refactoring Process

  1. Identify all state variables in the component
  2. For each one, ask: "Can this be computed from other state or props?"
  3. If yes: Replace the useState with a const (derived value)
  4. Remove the setter and all calls to it
  5. Remove any useEffect that was syncing the derived state
  6. Test to make sure everything still works

Before and After: Blog Post Editor

Before (5 state variables, 3 are unnecessary):

function BlogEditor() {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [wordCount, setWordCount] = useState(0);          // UNNECESSARY
  const [charCount, setCharCount] = useState(0);           // UNNECESSARY
  const [isPublishable, setIsPublishable] = useState(false); // UNNECESSARY

  useEffect(() => {
    setWordCount(body.split(/\s+/).filter(w => w).length);
  }, [body]);

  useEffect(() => {
    setCharCount(body.length);
  }, [body]);

  useEffect(() => {
    setIsPublishable(title.trim().length > 0 && wordCount >= 100);
  }, [title, wordCount]);

  return (
    <div>
      <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Title" />
      <textarea value={body} onChange={e => setBody(e.target.value)} placeholder="Write..." />
      <p>{wordCount} words, {charCount} characters</p>
      <button disabled={!isPublishable}>Publish</button>
    </div>
  );
}

After (2 state variables, everything else derived):

function BlogEditor() {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  // Derived
  const wordCount = body.split(/\s+/).filter(w => w).length;
  const charCount = body.length;
  const isPublishable = title.trim().length > 0 && wordCount >= 100;

  return (
    <div>
      <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Title" />
      <textarea value={body} onChange={e => setBody(e.target.value)} placeholder="Write..." />
      <p>{wordCount} words, {charCount} characters</p>
      <button disabled={!isPublishable}>Publish</button>
    </div>
  );
}

Changes:

  • Removed 3 useState calls
  • Removed 3 useEffect calls
  • Replaced with 3 const declarations
  • Same functionality, half the code, zero sync bugs
  • No more "extra render" from useEffect state updates

Before and After: E-commerce Filter

Before (overengineered):

function ProductPage() {
  const [products, setProducts] = useState([]);
  const [category, setCategory] = useState("all");
  const [priceRange, setPriceRange] = useState([0, 1000]);
  const [searchQuery, setSearchQuery] = useState("");
  const [filteredProducts, setFilteredProducts] = useState([]); // UNNECESSARY
  const [resultCount, setResultCount] = useState(0);             // UNNECESSARY
  const [categories, setCategories] = useState([]);               // UNNECESSARY
  const [priceMin, setPriceMin] = useState(0);                   // UNNECESSARY
  const [priceMax, setPriceMax] = useState(0);                   // UNNECESSARY

  useEffect(() => {
    const filtered = products
      .filter(p => category === "all" || p.category === category)
      .filter(p => p.price >= priceRange[0] && p.price <= priceRange[1])
      .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()));
    setFilteredProducts(filtered);
    setResultCount(filtered.length);
  }, [products, category, priceRange, searchQuery]);

  useEffect(() => {
    setCategories([...new Set(products.map(p => p.category))]);
    setPriceMin(Math.min(...products.map(p => p.price), 0));
    setPriceMax(Math.max(...products.map(p => p.price), 0));
  }, [products]);

  // ... render ...
}

After (clean):

function ProductPage() {
  const [products, setProducts] = useState([]);
  const [category, setCategory] = useState("all");
  const [priceRange, setPriceRange] = useState([0, 1000]);
  const [searchQuery, setSearchQuery] = useState("");

  // All derived
  const filteredProducts = products
    .filter(p => category === "all" || p.category === category)
    .filter(p => p.price >= priceRange[0] && p.price <= priceRange[1])
    .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()));

  const resultCount = filteredProducts.length;
  const categories = [...new Set(products.map(p => p.category))];
  const priceMin = products.length > 0 ? Math.min(...products.map(p => p.price)) : 0;
  const priceMax = products.length > 0 ? Math.max(...products.map(p => p.price)) : 0;

  // ... render ...
}

Removed: 5 useState calls, 2 useEffect blocks. Same behavior, simpler code.


12. Performance Considerations

When Derived Computation Gets Expensive

function DataAnalytics({ rawData }) {
  // rawData has 100,000 rows

  // This might be slow without memoization:
  const aggregated = rawData.reduce((acc, row) => {
    // Complex aggregation logic
    const bucket = getBucket(row.timestamp);
    acc[bucket] = acc[bucket] || { count: 0, sum: 0, min: Infinity, max: -Infinity };
    acc[bucket].count += 1;
    acc[bucket].sum += row.value;
    acc[bucket].min = Math.min(acc[bucket].min, row.value);
    acc[bucket].max = Math.max(acc[bucket].max, row.value);
    return acc;
  }, {});

  // If any OTHER state changes (e.g., a tooltip position), this aggregation
  // runs again even though rawData hasn't changed.
}

Fix with useMemo

function DataAnalytics({ rawData }) {
  const aggregated = useMemo(() => {
    return rawData.reduce((acc, row) => {
      const bucket = getBucket(row.timestamp);
      acc[bucket] = acc[bucket] || { count: 0, sum: 0, min: Infinity, max: -Infinity };
      acc[bucket].count += 1;
      acc[bucket].sum += row.value;
      acc[bucket].min = Math.min(acc[bucket].min, row.value);
      acc[bucket].max = Math.max(acc[bucket].max, row.value);
      return acc;
    }, {});
  }, [rawData]); // Only recompute when rawData changes

  // Now tooltip position changes don't trigger the expensive aggregation
}

Measuring Before Optimizing

function MaybeExpensive({ items }) {
  console.time("derive");
  const result = items.filter(/* ... */).map(/* ... */).reduce(/* ... */);
  console.timeEnd("derive");
  // Check: is this > 1ms? If not, useMemo adds complexity for no benefit.

  return <div>{/* ... */}</div>;
}

Chaining Derived Values

Sometimes derived values depend on other derived values. This is fine:

function Dashboard({ orders }) {
  // Level 1: from props
  const completedOrders = orders.filter(o => o.status === "completed");
  const pendingOrders = orders.filter(o => o.status === "pending");

  // Level 2: from level 1
  const completedRevenue = completedOrders.reduce((sum, o) => sum + o.total, 0);
  const pendingRevenue = pendingOrders.reduce((sum, o) => sum + o.total, 0);
  const totalRevenue = completedRevenue + pendingRevenue;

  // Level 3: from levels 1 and 2
  const averageOrderValue = completedOrders.length > 0
    ? completedRevenue / completedOrders.length
    : 0;
  const completionRate = orders.length > 0
    ? (completedOrders.length / orders.length * 100).toFixed(1)
    : "0.0";

  // Each level builds on the previous. All recompute together when orders changes.
}

13. Real-World Refactoring Examples

Refactoring a Search Component

Before:

function UserSearch() {
  const [users, setUsers] = useState([]);
  const [query, setQuery] = useState("");
  const [filteredUsers, setFilteredUsers] = useState([]);
  const [resultCount, setResultCount] = useState(0);
  const [hasResults, setHasResults] = useState(false);
  const [isSearching, setIsSearching] = useState(false);

  useEffect(() => {
    const filtered = users.filter(u =>
      u.name.toLowerCase().includes(query.toLowerCase())
    );
    setFilteredUsers(filtered);
    setResultCount(filtered.length);
    setHasResults(filtered.length > 0);
    setIsSearching(query.length > 0);
  }, [users, query]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {isSearching && <p>{resultCount} result{resultCount !== 1 ? "s" : ""}</p>}
      {isSearching && !hasResults && <p>No users found.</p>}
      <ul>
        {filteredUsers.map(u => <li key={u.id}>{u.name}</li>)}
      </ul>
    </div>
  );
}

After:

function UserSearch() {
  const [users, setUsers] = useState([]);
  const [query, setQuery] = useState("");

  const filteredUsers = users.filter(u =>
    u.name.toLowerCase().includes(query.toLowerCase())
  );
  const resultCount = filteredUsers.length;
  const hasResults = resultCount > 0;
  const isSearching = query.length > 0;

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {isSearching && <p>{resultCount} result{resultCount !== 1 ? "s" : ""}</p>}
      {isSearching && !hasResults && <p>No users found.</p>}
      <ul>
        {filteredUsers.map(u => <li key={u.id}>{u.name}</li>)}
      </ul>
    </div>
  );
}

Removed: 4 useState, 1 useEffect. Same exact output.

Refactoring a Pagination Component

Before:

function PaginatedList({ items, pageSize = 10 }) {
  const [currentPage, setCurrentPage] = useState(1);
  const [pageItems, setPageItems] = useState([]);
  const [totalPages, setTotalPages] = useState(0);
  const [canGoPrev, setCanGoPrev] = useState(false);
  const [canGoNext, setCanGoNext] = useState(false);
  const [pageStart, setPageStart] = useState(0);
  const [pageEnd, setPageEnd] = useState(0);

  useEffect(() => {
    const total = Math.ceil(items.length / pageSize);
    setTotalPages(total);
    setCanGoPrev(currentPage > 1);
    setCanGoNext(currentPage < total);
    const start = (currentPage - 1) * pageSize;
    const end = Math.min(start + pageSize, items.length);
    setPageStart(start + 1);
    setPageEnd(end);
    setPageItems(items.slice(start, end));
  }, [items, currentPage, pageSize]);

  return (
    <div>
      <ul>{pageItems.map(item => <li key={item.id}>{item.name}</li>)}</ul>
      <div>
        <button onClick={() => setCurrentPage(p => p - 1)} disabled={!canGoPrev}>Prev</button>
        <span>{pageStart}-{pageEnd} of {items.length} (Page {currentPage}/{totalPages})</span>
        <button onClick={() => setCurrentPage(p => p + 1)} disabled={!canGoNext}>Next</button>
      </div>
    </div>
  );
}

After:

function PaginatedList({ items, pageSize = 10 }) {
  const [currentPage, setCurrentPage] = useState(1);

  const totalPages = Math.ceil(items.length / pageSize);
  const canGoPrev = currentPage > 1;
  const canGoNext = currentPage < totalPages;
  const startIndex = (currentPage - 1) * pageSize;
  const endIndex = Math.min(startIndex + pageSize, items.length);
  const pageItems = items.slice(startIndex, endIndex);
  const pageStart = startIndex + 1;
  const pageEnd = endIndex;

  return (
    <div>
      <ul>{pageItems.map(item => <li key={item.id}>{item.name}</li>)}</ul>
      <div>
        <button onClick={() => setCurrentPage(p => p - 1)} disabled={!canGoPrev}>Prev</button>
        <span>{pageStart}-{pageEnd} of {items.length} (Page {currentPage}/{totalPages})</span>
        <button onClick={() => setCurrentPage(p => p + 1)} disabled={!canGoNext}>Next</button>
      </div>
    </div>
  );
}

Removed: 6 useState, 1 useEffect. Only 1 state variable remains (currentPage), and everything else derives from it plus the items prop.


14. Decision Framework

Use this framework for every piece of data in your component:

Is this data...

1. Constant (never changes)?
   --> const THING = value; (outside component or at top)

2. From a parent via props?
   --> Use the prop directly. Don't copy into state.

3. Computable from other state or props?
   --> Derive it: const thing = computeFrom(state, props);

4. Expensive to compute and inputs rarely change?
   --> const thing = useMemo(() => compute(state), [state]);

5. Changes over time AND cannot be computed from anything else?
   --> useState (this is actual state!)

6. Changes over time but shouldn't trigger re-renders?
   --> useRef

7. From the URL?
   --> useSearchParams / useParams (router)

8. From an API?
   --> Server state library (TanStack Query, SWR)

Quick Reference

                        Can it be computed?
                        /              \
                      YES               NO
                      /                   \
              Is it expensive?      Does changing it
              /          \          need a re-render?
            YES          NO         /           \
            /              \      YES            NO
       useMemo        const =   useState       useRef
       during          compute
       render          during
                       render

Key Takeaways

  1. If you can compute it, don't store it. Derived values should be regular variables, not state.
  2. useEffect for syncing state is almost always wrong. If you're watching state A to update state B, B is probably derived from A.
  3. Single source of truth. Every piece of data should have exactly one owner. Everything else is derived.
  4. Derived values are always in sync. They recompute on every render, so they can never be stale.
  5. Start simple. Compute during render. Add useMemo only when you measure slowness.
  6. Normalize complex state. Flat structures with ID references are easier to update than deeply nested objects.
  7. Common derived patterns: filtered lists, counts, totals, validation results, boolean checks, display strings.
  8. Removing derived state simplifies code. Fewer useState, fewer useEffect, fewer bugs.
  9. The test: If you always update X alongside Y, X is probably derived from Y.
  10. Derived state is free (or cheap). Most computations take microseconds. Don't fear re-computing.

Explain-It Challenge

Explain to a non-programmer why a spreadsheet doesn't need you to manually update cell C1 when C1's formula is =A1+B1 and you change A1. The spreadsheet computes C1 automatically from its sources. That's derived state.

Now explain why storing the result of =A1+B1 in a separate cell D1 and trying to keep D1 in sync with C1 manually is both unnecessary and error-prone. That's the anti-pattern of storing derived values in state.


Navigation: ← 2.3.d — Batching State Updates · Next → 2.3.f — Practical Build