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
useStatebecause that creates synchronization bugs and unnecessary complexity.
Navigation: ← 2.3.d — Batching State Updates · Next → 2.3.f — Practical Build
Table of Contents
- What Is Derived State
- The Problem: Duplicating State
- Identifying Derived State Opportunities
- Computing Values During Render
- Common Derived State Examples
- When to Use useMemo
- The useEffect Anti-Pattern
- Single Source of Truth
- State Normalization Patterns
- Computed Properties Pattern
- Refactoring: Removing Unnecessary State
- Performance Considerations
- Real-World Refactoring Examples
- Decision Framework
- Key Takeaways
- 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:
| Question | If 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
| Scenario | useMemo? | Why |
|---|---|---|
| Filtering 50 items | No | Array.filter on 50 items is ~0.01ms |
| Filtering 50,000 items | Yes | Could take 5-10ms per render |
| Sorting 100 items | No | Fast enough |
| Sorting 10,000 items | Possibly | Depends on comparator complexity |
| Complex regex on short string | No | Milliseconds at most |
| Parsing a large JSON blob | Yes | Could be expensive |
| Simple arithmetic (sum, count) | No | Always fast |
| Generating a large data structure | Yes | Object creation can be expensive at scale |
| Computing a value used in many child props | Possibly | If it prevents unnecessary child re-renders |
The Rule of Thumb
- Start without useMemo (just compute during render)
- Measure if you suspect a performance issue (React DevTools Profiler)
- 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
-
Extra render: The component renders with stale
filteredProducts, thenuseEffectfires, updates state, causing a second render with the correct data. The user briefly sees wrong data. -
Unnecessary complexity: You have state, an effect, and a dependency array for something that could be one line.
-
Potential for stale UI: On the first render after
productschanges, the component uses the OLDfilteredProducts. 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:
- Fetching data from an API:
useEffect(() => {
fetch("/api/products").then(r => r.json()).then(setProducts);
}, []);
// This is an external side effect, not a derivation
- Subscribing to external data sources:
useEffect(() => {
const unsubscribe = websocket.subscribe(data => setMessages(prev => [...prev, data]));
return unsubscribe;
}, []);
- 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
| Aspect | Unnormalized | Normalized |
|---|---|---|
| Update a user's email | Find all copies, update each | Update in ONE place |
| Check if entity exists | Traverse nested structures | users[id] !== undefined |
| Avoid data duplication | Impossible | By design |
| Memory usage | Higher (duplicated data) | Lower |
| Read a single entity | May need to search | Direct lookup: O(1) |
| Complexity of updates | High (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
- Identify all state variables in the component
- For each one, ask: "Can this be computed from other state or props?"
- If yes: Replace the
useStatewith aconst(derived value) - Remove the setter and all calls to it
- Remove any
useEffectthat was syncing the derived state - 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
useStatecalls - Removed 3
useEffectcalls - Replaced with 3
constdeclarations - 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
- If you can compute it, don't store it. Derived values should be regular variables, not state.
- useEffect for syncing state is almost always wrong. If you're watching state A to update state B, B is probably derived from A.
- Single source of truth. Every piece of data should have exactly one owner. Everything else is derived.
- Derived values are always in sync. They recompute on every render, so they can never be stale.
- Start simple. Compute during render. Add
useMemoonly when you measure slowness. - Normalize complex state. Flat structures with ID references are easier to update than deeply nested objects.
- Common derived patterns: filtered lists, counts, totals, validation results, boolean checks, display strings.
- Removing derived state simplifies code. Fewer
useState, feweruseEffect, fewer bugs. - The test: If you always update X alongside Y, X is probably derived from Y.
- 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