← Back to Redux
Lesson 4 · State Management Fundamentals

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.

Beginner40 min

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.

request flowOne name, three places, two stalestep 1 / 3

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.

Header
Ravi
Greeting
Welcome back, Ravi
Form
Ravi
places
3

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.

request flowAnswers that arrive in the wrong orderstep 1 / 3

1 - Two requests are sent

Each key press sends a search. "re" is sent first, "redux" second. The server happens to answer "re" slowly.

sent 1st
"re" (200 ms)
sent 2nd
"redux" (50 ms)
box shows
redux
screen shows
-

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.

What grows
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.

ThreePlaces.jsx
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"));
What the screen showed - measured
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 copy

Tip: 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.

derived.mjs
// 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));
Output - node derived.mjs
items: [ 'Redux' ] stored total: 1498 | real total: 999 derived total: 999

Failure 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.

owner.mjs - anyone can change 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");
Output
currency is now: EUR - who set it? when? no record
owner-store.mjs - one door, and a record of every change
import { 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);
Output
action: settings/currencyChanged {"currency":"USD","from":"checkoutPage"} action: settings/currencyChanged {"currency":"EUR","from":"priceWidget"} currency is now: EUR

Failure 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.

Mutate.jsx
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> ); }
Clicking the buttons - measured
click "Add (wrong)" renders: 0 screen: Learn state click "Add (right)" renders: 1 screen: Learn state, Learn Redux, Learn Redux ^ the hidden change appears now

Failure 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.

race.mjs
// 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);
Output - node race.mjs
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 -> redux

The 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.

Failure -> symptom -> rule that prevents it
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.

Redux’s three principles
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

Duplication

One fact stored in several places.

Drift

The copies stop agreeing.

Derived value

Calculated from other state - do not store it.

const total = items.reduce(...)
Ownership

Who may change a value, and how.

store.dispatch(currencyChanged(...))
Immutable update

Make a new object instead of editing.

setItems([...items, item])
Stale answer

An 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.

Find the copies

“In ThreePlaces.jsx, make the Greeting read the store instead of localStorage. How many stale places are left?”

Fix the form

“Give ProfileForm a key={name} from ProfilePage. What happens to the form after a rename, and why?”

Break the total

“In derived.mjs, add a changePrice function that forgets the total too. Then delete the stored total completely.”

Change the delays

“In race.mjs, swap the delays so "re" is fast. Does the bug still happen? Why is that worse?”

What usually goes wrong

Copying a prop into state and expecting it to follow

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 current
Storing a value you can calculate

Every 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);
Changing shared objects directly

There is no record of who changed what. Change shared state through one named door.

✗ settings.currency = "EUR";
✓ store.dispatch(currencyChanged({ currency: "EUR" }));
Editing an array in place

React sees the same array and does not re-render. Make a new one.

✗ items.push(item); setItems(items);
✓ setItems([...items, item]);
Trusting the last answer to arrive

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.

1.

In ThreePlaces.jsx, which two places are stale after the rename, and why is each one stale?

Show hint

When was each copy made, and does anything update it later?

Show solution
Answer
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.
2.

A message list stores messages and also unreadCount. Name the failure, and rewrite unreadCount so it can never be wrong.

Show hint

Can unreadCount be calculated from the messages?

Show solution
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 }])); // 2
3.

For 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 hint

Use the "six failures" table.

Show solution
Answer
(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)
Coding challenge

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.

It should
  • 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
Starter - prints count: 2, lastAdded: Redux course, and "changed in place"
// 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 place
Show one solution
Solution - checked
// 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 changes

Key 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

What is duplication, and what is drift?
Duplication is storing the same fact in more than one place. Drift is when those copies stop agreeing.
Why did the form still show "Ravi" after the rename?
It copied the prop into useState, and useState uses its first value only once.
What is a derived value? Give an example.
A value that can be calculated from other state, like a cart total from the items. It should not be stored.
Why did "Add (wrong)" not update the screen?
It changed the same array and passed it back to React. React saw the same array and did not re-render.
How do you stop an old search answer from replacing a newer one?
Number each request, remember the latest, and ignore any answer that is not from the latest request.

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. 1.

    After a rename in the store, how many of the three places in Example 1 were stale?

  2. 2.

    The stored total said 1498 but the items added up to 999. Which failure is this?

  3. 3.

    With the Redux logger, what did every change have that the plain settings object did not?

  4. 4.

    After "Add (wrong)" then "Add (right)", why did "Learn Redux" appear twice?

  5. 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...