Why State Management Becomes Difficult
Six ways state goes wrong as an app grows - duplicated values, drifting copies, stored totals, changes nobody can trace, changes React never sees, and answers that arrive in the wrong order. Each one reproduced with running code, and each one fixed.
What you will be able to do
- Explain why state that is easy in a small app becomes hard in a big one
- Recognise duplication and drift, and reproduce them: one name in three places
- Stop storing values that can be calculated (derived state)
- Explain unclear ownership, and how named actions make every change traceable
- See why changing an object in place hides the change from React
- See how two async answers arriving in the wrong order show old data, and fix it
- Connect each failure to the rule that prevents it - and to Redux’s three principles
The idea, in plain English
Lessons 1 to 3 showed one problem at a time. Real apps have all of them at once. This lesson collects the ways state goes wrong, so that when you meet a strange bug in a real project, you can name it - and when you meet Redux’s rules in Module 2, you know which bug each rule prevents.
In a small app, you can keep every piece of state in your head. You know where the user’s name is stored, who changes it, and which screens show it. As the app grows - more screens, more developers, more years, more server requests - nobody can keep it all in their head any more. Then the same few mistakes appear again and again.
The outline names three of them: duplication (the same value stored in more than one place), drift (those copies stop agreeing), and unclear ownership (the value can be changed from anywhere, and nobody knows who changed it). We add three more that you will meet just as often: storing values you could calculate, changing objects in place, and async answers that arrive in the wrong order.
Every failure below was reproduced with running code: plain JavaScript in Node 22, and React 19.3 with Redux Toolkit 2.13.0, rendered and clicked in a test browser (jsdom).
Worked example: The same user name stored in three places - after a rename, two of them are stale.
1 - At start, all three agree
The store has "Ravi". At start-up the app also saved the name to localStorage, and the profile form copied it into its own useState. Three places, one value - for now.
Example 1, a real run. The user’s name is stored in the Redux store, copied to localStorage at start-up, and copied into a form’s useState.
1 - Two requests are sent
Each key press sends a search. "re" is sent first, "redux" second. The server happens to answer "re" slowly.
Example 6, a real run. The user types "re", then "redux". The first request is slow.
An everyday picture: a friend’s phone number in three places
Your friend’s phone number is saved in three places: in your phone’s contacts, on your old SIM card, and written in a paper diary. One day your friend changes their number. You update your phone’s contacts. Months later, using the old SIM in another phone, you call the old number. Someone else answers.
Nothing was "wrong" with any single place - each one saved what it was given. The problem is that one fact was stored three times, and only one copy was updated. Worse, when you finally notice, you cannot tell which copy is right without asking your friend again.
This is exactly what happens to state in a growing app. The fix in real life is the same as in code: keep the number in one place, and make everything else look it up there.
Why it is easy at first and hard later
A to-do app with three components has maybe five pieces of state. You can see all of them on one screen of code. A real product has hundreds of components, written by different people over several years, talking to a server that other people also change.
None of the problems below is caused by a "bad developer". Each one starts as a small, reasonable shortcut: "I will just copy this prop into state", "I will store the total so I do not have to calculate it", "I will just change this one field". Each shortcut is fine on its own. Together, and over time, they make the app unpredictable.
ComponentsMore places that read and change the same data.DevelopersNobody knows every place a value is stored or changed.TimeShortcuts from two years ago are still in the code.Server requestsData arrives late, fails, or arrives out of order.Failure 1 and 2: duplication and drift
Duplication means the same fact is stored in more than one place. Drift is what happens next: one copy changes and the others do not, so they stop agreeing. Duplication is the cause; drift is the bug you see.
This is the example from the course outline: one user name stored in three places. Place 1 is the Redux store. Place 2 is localStorage, where the app saved the name when it started. Place 3 is a form that copied the name into its own useState. We dispatched a rename to "Ravi Kumar". The header, which reads the store, updated. The greeting (from localStorage) and the form (from its own copy) still said "Ravi".
The form is the sneakiest copy. useState(name) uses name only the first time the component renders. When the prop changes later, the state does not follow. Copying a prop into state is one of the most common ways duplication sneaks into React code.
import { useState } from "react";
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Provider, useSelector } from "react-redux";
// Place 1: the store - the "real" value
const userSlice = createSlice({
name: "user",
initialState: { name: "Ravi" },
reducers: {
renamed(state, action) {
state.name = action.payload;
},
},
});
export const { renamed } = userSlice.actions;
export const store = configureStore({ reducer: { user: userSlice.reducer } });
// Place 2: a copy saved in the browser when the app started
localStorage.setItem("userName", store.getState().user.name);
function Header() {
const name = useSelector((state) => state.user.name); // reads the store
return <header>Header: {name}</header>;
}
function Greeting() {
const savedName = localStorage.getItem("userName"); // reads the saved copy
return <p>Welcome back, {savedName}</p>;
}
function ProfileForm({ name }) {
const [draft, setDraft] = useState(name); // Place 3: copies the prop ONCE
return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;
}
function ProfilePage() {
const name = useSelector((state) => state.user.name);
return <ProfileForm name={name} />;
}
export default function App() {
return (
<Provider store={store}>
<Header />
<Greeting />
<ProfilePage />
</Provider>
);
}
// later, somewhere in the app:
// store.dispatch(renamed("Ravi Kumar"));start Header: Ravi | Welcome back, Ravi | form: Ravi | store: Ravi | localStorage: Ravi
renamed Header: Ravi Kumar | Welcome back, Ravi | form: Ravi | store: Ravi Kumar | localStorage: Ravi
^ stale ^ stale ^ stale copyTip: Drift bugs are hard to find because they depend on history: the greeting is right or wrong depending on whether the user renamed themselves since the app started. If a bug "only happens sometimes", look for a copy.
Failure 3: storing what you could calculate
Some values depend completely on other values. The cart total depends on the items; the number of unread messages depends on the messages. These are called derived values. If you store them as well, you have duplication again - two facts that must always agree, and every change must remember to update both.
In the example, addItem remembered to update the total, but removeItem forgot. After adding Redux (999) and removing Python (499), the stored total said 1498, while the real total of the items was 999. The fix is not "remember better" - it is to stop storing the total and calculate it from the items whenever you need it.
// The cart stores the items AND the total - two values that must agree
const cart = { items: [{ name: "Python", price: 499 }], total: 499 };
function addItem(item) {
cart.items.push(item);
cart.total += item.price; // remembered to update the total
}
function removeItem(name) {
cart.items = cart.items.filter((item) => item.name !== name);
// forgot to update the total!
}
addItem({ name: "Redux", price: 999 });
removeItem("Python");
const realTotal = cart.items.reduce((sum, item) => sum + item.price, 0);
console.log("items:", cart.items.map((item) => item.name));
console.log("stored total:", cart.total, "| real total:", realTotal);
// The fix: do not store the total at all - calculate it from the items
const total = (items) => items.reduce((sum, item) => sum + item.price, 0);
console.log("derived total:", total(cart.items));items: [ 'Redux' ]
stored total: 1498 | real total: 999
derived total: 999Failure 4: unclear ownership
Ownership means: who is allowed to change this value, and how? When a value is a plain object that any file can change, the honest answer is "anyone, any way". Then, when the value is wrong, you cannot find out who changed it or when - there is no record. You add console.log lines in ten files and hope.
In the first example, the checkout page, a third-party widget and the profile page all change settings.currency directly. After two of them run, the currency is "EUR" - and nothing tells you which code set it.
In the second example, the same changes go through a Redux store. The only way to change the currency is to dispatch a named action, settings/currencyChanged. A tiny logger runs for every action, so every change is written down in order, with its data. (We put a from field in the payload only so you can see where each change came from; usually the action’s name and the Redux DevTools are enough.) This is the main reason teams choose Redux: every change goes through one door, and you can see everything that passes through it.
// A settings object that any file can change directly
export const settings = { currency: "INR" };
// Somewhere in checkout.js ...
function checkoutPage() { settings.currency = "USD"; }
// Somewhere in a third-party widget ...
function priceWidget() { settings.currency = "EUR"; }
// Somewhere in the profile page ...
function profilePage() { settings.currency = "INR"; }
checkoutPage();
priceWidget();
console.log("currency is now:", settings.currency, "- who set it? when? no record");currency is now: EUR - who set it? when? no recordimport { configureStore, createSlice } from "@reduxjs/toolkit";
const settingsSlice = createSlice({
name: "settings",
initialState: { currency: "INR" },
reducers: {
currencyChanged(state, action) {
state.currency = action.payload.currency;
},
},
});
const { currencyChanged } = settingsSlice.actions;
// A tiny "logger": runs for every action, before the reducer
const logger = (store) => (next) => (action) => {
console.log("action:", action.type, JSON.stringify(action.payload));
return next(action);
};
const store = configureStore({
reducer: { settings: settingsSlice.reducer },
middleware: (getDefault) => getDefault().concat(logger),
});
store.dispatch(currencyChanged({ currency: "USD", from: "checkoutPage" }));
store.dispatch(currencyChanged({ currency: "EUR", from: "priceWidget" }));
console.log("currency is now:", store.getState().settings.currency);action: settings/currencyChanged {"currency":"USD","from":"checkoutPage"}
action: settings/currencyChanged {"currency":"EUR","from":"priceWidget"}
currency is now: EURFailure 5: changes nobody sees
React decides whether to re-render by asking a simple question: is this a different object from before? If you change an array in place with push and then give React the same array, React compares it with the old one, sees the same array, and does nothing.
We clicked "Add (wrong)": the component rendered 0 times and the screen still showed one item. But the array had secretly changed. Then we clicked "Add (right)", which makes a new array - and the screen jumped to show "Learn Redux" twice: once from the hidden change, once from the new one. A change that is invisible now and appears later, mixed with another change, is very hard to debug.
This is why Redux requires immutable updates - make a new object or array instead of editing the old one. In Lesson 1 you saw Redux Toolkit freeze the state so you cannot edit it by accident.
import { useState } from "react";
export default function TodoList() {
const [items, setItems] = useState(["Learn state"]);
function addWrong() {
items.push("Learn Redux"); // changes the SAME array
setItems(items); // React sees the same array: "nothing changed"
}
function addRight() {
setItems([...items, "Learn Redux"]); // a NEW array
}
return (
<div>
<button onClick={addWrong}>Add (wrong)</button>
<button onClick={addRight}>Add (right)</button>
<ul>{items.map((item, i) => <li key={i}>{item}</li>)}</ul>
</div>
);
}click "Add (wrong)" renders: 0 screen: Learn state
click "Add (right)" renders: 1 screen: Learn state, Learn Redux, Learn Redux
^ the hidden change appears nowFailure 6: answers in the wrong order
When state comes from the server, time becomes part of the problem. Requests do not always come back in the order you sent them. In the second diagram, the user types "re" and then "redux". The "re" request is slow (200 ms) and the "redux" request is fast (50 ms). The code shows whichever answer arrives last - so the screen ends with the results for "re", while the box says "redux".
The fix is to give each request a number and remember the latest one. When an answer arrives, check: is this from the latest request? If not, ignore it. Our run printed "ignored old answer" and the screen kept the "redux" results. In Module 10, RTK Query handles this kind of problem for you.
// A search box. Each key press sends a request. The server is sometimes slow.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function searchServer(text, delay) {
await wait(delay);
return text + " -> " + (text === "re" ? "react, redux, regex" : "redux");
}
let results = ""; // the state the screen shows
async function onType(text, delay) {
const answer = await searchServer(text, delay);
results = answer; // whichever answer arrives LAST wins
console.log(" answer arrived:", answer);
}
console.log("user types 're', then 'redux'");
await Promise.all([onType("re", 200), onType("redux", 50)]);
console.log("screen shows:", results, "<- results for the OLD text");
// The fix: remember the latest request, and ignore older answers
let latest = 0;
async function onTypeSafe(text, delay) {
const id = ++latest;
const answer = await searchServer(text, delay);
if (id !== latest) {
console.log(" ignored old answer:", answer);
return;
}
results = answer;
}
await Promise.all([onTypeSafe("re", 200), onTypeSafe("redux", 50)]);
console.log("screen shows:", results);user types 're', then 'redux'
answer arrived: redux -> redux
answer arrived: re -> react, redux, regex
screen shows: re -> react, redux, regex <- results for the OLD text
ignored old answer: re -> react, redux, regex
screen shows: redux -> reduxThe six failures, side by side
Every one of these problems has the same root: more than one place, more than one way, or more than one moment where the state can change, without a rule to keep them in order.
1. DuplicationSymptom: the same value in several places. Rule: keep one copy (single source of truth).2. DriftSymptom: screens disagree, "only sometimes". Rule: everything reads the one copy.3. Stored derived valuesSymptom: a total or count that is wrong. Rule: calculate it from the source.4. Unclear ownershipSymptom: a wrong value and no idea who set it. Rule: change state only through named actions.5. Changes nobody seesSymptom: the screen does not update, then jumps later. Rule: make new objects, never edit old ones.6. Wrong orderSymptom: old results replace new ones. Rule: track requests and ignore old answers.Where Redux comes in: three principles
Redux was designed around these failures. Its documentation states three principles, and you can now see which bugs each one prevents. You will study them properly in Module 2.
Single source of truth: the whole app’s shared state lives in one store - so there is one copy to read (failures 1 and 2). State is read-only: the only way to change it is to dispatch an action that describes what happened - so every change has a name and can be logged (failure 4). Changes are made with pure functions: reducers take the old state and an action and return a new state, without editing the old one - so every change is visible (failure 5). Derived values (failure 3) are handled by selectors in Module 7, and async order (failure 6) by the async tools in Modules 8 and 10.
Redux is not the only way to follow these rules - Lesson 1’s small store and Lesson 2’s "lift state up" already follow some of them. But Redux makes them the default, which matters most when many people work on the same app.
Single source of truthOne store holds the shared state. Prevents duplication and drift.State is read-onlyChange it only by dispatching actions. Makes ownership clear and every change traceable.Changes with pure functionsReducers return new state, never edit the old. Every change is visible.Common questions
Is copying a prop into useState always wrong? No - it is fine when you want an independent starting value, like a form that edits a draft and saves it later. Then name it clearly (initialName) and remember it will not follow later changes. It is wrong when you expect it to stay in sync.
Is localStorage bad? No. It is useful for remembering things across reloads, like this site’s theme. The bug in Example 1 was reading the saved copy for display instead of the live value. Save to it, but show the one source of truth.
Will Redux fix all of these automatically? No. Redux makes the right way easy and the wrong way visible, but you can still copy store data into useState, store a total next to the items, or show an old async answer. The rules are what matter; Redux helps you follow them.
This lesson at a glance
DuplicationOne fact stored in several places.
DriftThe copies stop agreeing.
Derived valueCalculated from other state - do not store it.
const total = items.reduce(...)
OwnershipWho may change a value, and how.
store.dispatch(currencyChanged(...))
Immutable updateMake a new object instead of editing.
setItems([...items, item])
Stale answerAn older request’s answer arriving last.
if (id !== latest) return;
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“In ThreePlaces.jsx, make the Greeting read the store instead of localStorage. How many stale places are left?”
“Give ProfileForm a key={name} from ProfilePage. What happens to the form after a rename, and why?”
“In derived.mjs, add a changePrice function that forgets the total too. Then delete the stored total completely.”
“In race.mjs, swap the delays so "re" is fast. Does the bug still happen? Why is that worse?”
What usually goes wrong
useState uses its argument only on the first render. Read the prop directly, or use a key to start fresh.
✗ const [name, setName] = useState(props.name); // never updates✓ const name = props.name; // always currentEvery change must remember to update both, and one day one will not. Calculate it.
✗ cart = { items, total }✓ const total = items.reduce((sum, i) => sum + i.price, 0);There is no record of who changed what. Change shared state through one named door.
✗ settings.currency = "EUR";✓ store.dispatch(currencyChanged({ currency: "EUR" }));React sees the same array and does not re-render. Make a new one.
✗ items.push(item); setItems(items);✓ setItems([...items, item]);Requests can come back in any order. Ignore answers that are not from the latest request.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
In ThreePlaces.jsx, which two places are stale after the rename, and why is each one stale?
Show hintHide hint
When was each copy made, and does anything update it later?
Show solutionHide solution
localStorage: the name was saved once, when the app started. Nothing saves it again.
ProfileForm: useState(name) uses name only on the first render. Later prop changes
do not change the state.
Only Header is right, because it reads the store every time.A message list stores messages and also unreadCount. Name the failure, and rewrite unreadCount so it can never be wrong.
Show hintHide hint
Can unreadCount be calculated from the messages?
Show solutionHide solution
// Failure 3: a stored derived value. Calculate it instead:
const unreadCount = (messages) => messages.filter((m) => !m.read).length;
console.log(unreadCount([{ read: true }, { read: false }, { read: false }])); // 2For each symptom, name the failure: (a) "the total is wrong after deleting an item", (b) "the header shows the old name until I reload", (c) "the setting changed and nobody knows which code did it", (d) "I added an item but the list did not update".
Show hintHide hint
Use the "six failures" table.
Show solutionHide solution
(a) stored derived value (3)
(b) duplication and drift (1, 2) - a stale copy
(c) unclear ownership (4)
(d) a change nobody sees - edited in place (5)Fix a small store with three problems
This cart "store" has three of the failures from this lesson. Run it, find all three, and rewrite it so the output is correct.
- count must always equal the number of items - do not store it
- lastAdded must show the current name even after a rename - store only its id
- Every change must create new objects (rename must not edit the old item), and call a listener
// A small cart "store" with THREE state problems. Find them.
const state = { items: [], count: 0, lastAdded: null };
function add(item) {
state.items.push(item);
state.count++;
state.lastAdded = { ...item };
}
function rename(id, name) {
const item = state.items.find((i) => i.id === id);
item.name = name;
}
function remove(id) {
state.items = state.items.filter((i) => i.id !== id);
}
add({ id: 1, name: "Python course" });
add({ id: 2, name: "Redux course" });
const itemBefore = state.items[1];
rename(2, "Redux Toolkit course");
const itemAfter = state.items[1];
remove(1);
console.log("items:", state.items.map((i) => i.name));
console.log("count:", state.count);
console.log("lastAdded:", state.lastAdded.name);
console.log("rename made a new object?", itemBefore !== itemAfter ? "yes" : "no - the old object was changed in place");
// items: [ 'Redux Toolkit course' ]
// count: 2 <- wrong (stored derived value)
// lastAdded: Redux course <- wrong (stale copy)
// rename made a new object? no - the old object was changed in placeShow one solutionHide solution
// Fixed: one copy of each fact, derived values calculated, every change makes new objects.
let state = { items: [], lastAddedId: null };
const listeners = [];
const setState = (next) => {
state = next;
listeners.forEach((listener) => listener());
};
// derived values: calculated from items, never stored
const count = () => state.items.length;
const lastAdded = () => state.items.find((i) => i.id === state.lastAddedId);
function add(item) {
setState({ ...state, items: [...state.items, item], lastAddedId: item.id });
}
function rename(id, name) {
setState({ ...state, items: state.items.map((i) => (i.id === id ? { ...i, name } : i)) });
}
function remove(id) {
setState({ ...state, items: state.items.filter((i) => i.id !== id) });
}
let changes = 0;
listeners.push(() => changes++);
add({ id: 1, name: "Python course" });
add({ id: 2, name: "Redux course" });
const itemBefore = state.items[1];
rename(2, "Redux Toolkit course");
const itemAfter = state.items[1];
remove(1);
console.log("items:", state.items.map((i) => i.name));
console.log("count:", count());
console.log("lastAdded:", lastAdded().name);
console.log("rename made a new object?", itemBefore !== itemAfter ? "yes" : "no - the old object was changed in place");
console.log("listeners told about", changes, "changes");
// items: [ 'Redux Toolkit course' ]
// count: 1
// lastAdded: Redux Toolkit course
// rename made a new object? yes
// listeners told about 4 changesKey points
- State gets hard as an app grows: more components, more developers, more time, more server requests.
- Duplication - one fact in several places - leads to drift, where the copies disagree.
- useState(prop) copies the prop once; later changes to the prop do not reach it.
- Do not store values you can calculate: derive totals and counts from the source.
- Unclear ownership means nobody knows who changed a value; named actions give every change a record.
- Editing objects in place hides changes from React; make new objects instead.
- Async answers can arrive in the wrong order; ignore answers that are not from the latest request.
- Redux’s three principles - single source of truth, read-only state, pure reducers - exist to prevent these failures.
Quick check before you move on
Interview questions
Why does state management become difficult in large frontend applications?
More components read and write the same data, more developers touch it, and more data arrives asynchronously. Small shortcuts - copied props, stored totals, direct mutations - accumulate into duplicated state that drifts, changes nobody can trace, updates the UI misses, and race conditions.
What is derived state, and how should you handle it?
State that can be computed from other state, like totals, counts or filtered lists. Do not store it; compute it from the source, memoising with a selector if it is expensive.
Why is copying props into state considered an anti-pattern?
useState uses its initial value once, so the copy stops following the prop. You get two sources of truth that drift. Read the prop directly, or, if a separate draft is really wanted, reset it deliberately with a key.
Why does Redux insist on immutable updates?
So changes are detectable by reference comparison - React and react-redux re-render when references change - and so every state version is a separate snapshot that can be logged, compared and replayed.
How would you prevent out-of-order responses from showing stale data?
Track the latest request and ignore older responses, or cancel previous requests with AbortController. Data-fetching libraries such as RTK Query handle this for you.
Quiz
- 1.
After a rename in the store, how many of the three places in Example 1 were stale?
- 2.
The stored total said 1498 but the items added up to 999. Which failure is this?
- 3.
With the Redux logger, what did every change have that the plain settings object did not?
- 4.
After "Add (wrong)" then "Add (right)", why did "Learn Redux" appear twice?
- 5.
Name Redux’s three principles.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned