Chapter 6: State Management: Choose Your Own Adventure in Hell
State management in React is like a Choose Your Own Adventure book where every choice leads to suffering, just different flavors of it. Do you want the suffering of prop drilling? The suffering of Context API re-renders? The suffering of Redux boilerplate? Or the suffering of choosing between 47 different state management libraries?
Welcome to state management in React, where the solution to every problem creates three new problems.
Local State: The Gateway Drug
It starts innocently enough. You just need a counter:
const Counter = () => {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
};
“Look how simple!” you think. “State management in React is easy!”
Then you need two components to share that count:
const Parent = () => {
const [count, setCount] = useState(0);
return (
<>
<Counter count={count} setCount={setCount} />
<Display count={count} />
</>
);
};
“Still manageable!” you tell yourself, ignoring the props you’re now passing around.
Then you need that count three components deep:
const App = () => {
const [count, setCount] = useState(0);
return <Level1 count={count} setCount={setCount} />;
};
const Level1 = ({ count, setCount }) => {
return <Level2 count={count} setCount={setCount} />;
};
const Level2 = ({ count, setCount }) => {
return <Level3 count={count} setCount={setCount} />;
};
const Level3 = ({ count, setCount }) => {
return <div>Finally! Count: {count}</div>;
};
Congratulations, you’ve discovered prop drilling, React’s favorite method of making simple things complicated.
Lifting State Up: Musical Chairs with Data
React’s solution to components needing to share state? “Lift it up!” Move the state to the nearest common ancestor. It’s like solving family disputes by making grandma hold everyone’s stuff.
// Before: Each component manages its own state
const ProfileForm = () => {
const [name, setName] = useState('');
// ...
};
const AddressForm = () => {
const [address, setAddress] = useState('');
// ...
};
// After: Parent holds everyone's state
const UserForm = () => {
const [name, setName] = useState('');
const [address, setAddress] = useState('');
return (
<>
<ProfileForm name={name} setName={setName} />
<AddressForm address={address} setAddress={setAddress} />
</>
);
};
Now your parent component is a bloated mess holding state for children who may not even need it. But hey, at least they can share!
The setState Batching Mystery
Here’s a fun React quirk that will ruin your day:
const Component = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// You'd think count would be 3, right?
// WRONG! It's 1.
};
};
React batches state updates for “performance.” So all three calls see count
as 0. Want to actually increment three times?
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// NOW it's 3
This is called “functional updates” and it’s React’s way of saying “gotcha!”
Context API: Prop Drilling’s Complicated Brother
React heard our complaints about prop drilling and gave us Context:
const CountContext = createContext();
const CountProvider = ({ children }) => {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value=8>
{children}
</CountContext.Provider>
);
};
const DeepComponent = () => {
const { count } = useContext(CountContext);
return <div>Count: {count}</div>;
};
“Problem solved!” React says. Except now:
- Every component that uses context re-renders when any context value changes
- You need to wrap your app in providers
- You need to remember to create contexts
- You need to handle the case where context doesn’t exist
- You’ve traded explicit prop passing for implicit magic
Redux: When You Need a Library to Manage a Library
Redux came along and said, “What if we made state management SO complicated that you’d need a computer science degree to update a counter?”
// actions/types.js
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
// actions/counter.js
export const increment = () => ({
type: INCREMENT
});
export const decrement = () => ({
type: DECREMENT
});
// reducers/counter.js
const initialState = { count: 0 };
export const counterReducer = (state = initialState, action) => {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
case DECREMENT:
return { ...state, count: state.count - 1 };
default:
return state;
}
};
// store.js
import { createStore } from 'redux';
import { counterReducer } from './reducers/counter';
export const store = createStore(counterReducer);
// App.js
import { Provider } from 'react-redux';
import { store } from './store';
const App = () => (
<Provider store={store}>
<Counter />
</Provider>
);
// Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './actions/counter';
const Counter = () => {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
};
That’s 50+ lines of code to manage a counter. In vanilla JavaScript:
let count = 0;
button.onclick = () => {
count++;
span.textContent = count;
};
But that’s not “scalable,” apparently.
The Global State Addiction
Once you have global state management, everything becomes global state:
const globalState = {
user: { /* user data */ },
theme: 'dark',
sidebarOpen: true,
modalVisible: false,
currentPage: 'home',
formData: { /* form fields */ },
mousePosition: { x: 0, y: 0 },
scrollPosition: 0,
windowSize: { width: 1920, height: 1080 },
isThinking: false,
coffeeLevel: 'critical',
willToLive: null
};
Your global state becomes a junk drawer where every piece of data lives, whether it needs to be global or not.
State Management Library Explosion
Not happy with Redux? Here’s your buffet of suffering:
MobX: “What if state management was magic?”
class TodoStore {
@observable todos = [];
@action addTodo = (text) => {
this.todos.push({ text, done: false });
}
}
Recoil: “What if state was atoms?”
const countState = atom({
key: 'countState',
default: 0,
});
Zustand: “What if Redux but simpler?”
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
Jotai: “What if atoms but different?”
const countAtom = atom(0);
Valtio: “What if proxies?”
const state = proxy({ count: 0 });
XState: “What if state machines?”
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } },
active: { on: { TOGGLE: 'inactive' } }
}
});
Each library promises to solve the problems of the previous library while creating exciting new problems of its own.
The Re-render Cascade
The dirty secret of React state management: change state, re-render everything.
const App = () => {
const [count, setCount] = useState(0);
console.log('App rendered');
return (
<>
<Header /> {/* Re-renders */}
<Main count={count} /> {/* Re-renders */}
<Footer /> {/* Re-renders */}
</>
);
};
Change one piece of state? Entire component tree re-renders. React’s solution? Memoization:
const Header = React.memo(() => {
console.log('Header rendered');
return <header>Header</header>;
});
Now you’re manually telling React which components shouldn’t re-render. You’re optimizing React’s optimizations.
State Synchronization Hell
When you have state in multiple places, keeping it synchronized becomes a full-time job:
const useUserData = () => {
const [localUser, setLocalUser] = useState(null);
const globalUser = useSelector(state => state.user);
const contextUser = useContext(UserContext);
useEffect(() => {
// Which user is the real user?
// Local? Global? Context?
// Let's sync them all and hope for the best!
if (globalUser && !localUser) {
setLocalUser(globalUser);
}
if (contextUser && contextUser !== localUser) {
setLocalUser(contextUser);
}
// Oh god what have we done
}, [globalUser, contextUser, localUser]);
return localUser || globalUser || contextUser || null;
};
You now have three sources of truth, and they’re all lying.
The Immutability Obsession
React demands immutability, so every state update must create new objects:
// Wrong (but intuitive)
state.user.name = 'New Name';
state.todos.push(newTodo);
// Right (but verbose)
setState({
...state,
user: {
...state.user,
name: 'New Name'
}
});
setState({
...state,
todos: [...state.todos, newTodo]
});
Spread operators everywhere! Because mutating objects is bad, but creating new objects every time you change anything is… good?
The Server State Problem
Then you realize half your “state” is actually server data:
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
Now you’re managing loading states, error states, cache invalidation, refetching… Enter React Query/SWR/Apollo, because you need a library to manage the state that your state management library doesn’t manage.
The Form State Special Hell
Forms in React deserve their own circle of hell:
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
acceptTerms: false,
newsletter: false,
country: '',
// ... 20 more fields
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
// Every input needs
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
This is “controlled components,” React’s way of making HTML forms require 10x more code.
In Defense of React State (I Can’t Believe I’m Doing This)
React’s state management does have some benefits:
- Predictability: You know when and how state changes
- DevTools: Time-travel debugging is actually cool
- Type Safety: With TypeScript, state can be type-safe
- Ecosystem: Solutions exist for every problem (too many solutions, but still)
But we’ve complicated something that used to be simple: storing and updating values.
The Reality Check
Here’s what we’re really doing: We’re managing the state of managing the state. We have state management libraries to manage the state that React manages, which manages the state of the DOM, which the browser already manages.
It’s state management all the way down.
The Path Forward
You’re going to use state management in React. You’ll probably use multiple solutions in the same app. You’ll have local state, context state, global state, server state, form state, and URL state. You’ll spend more time moving state around than actually building features.
And when someone suggests that maybe, just maybe, we’re overcomplicating things, you’ll say, “But how else would we manage state?”
Because you’ve forgotten that before React, we just had variables. And they worked fine.
In the next chapter, we’ll explore hooks - React’s magic functions that let you use state in functions, because classes were too simple and we needed to make things interesting.
But first, take a moment to appreciate the simplicity of var count = 0
. No providers, no reducers, no actions, no dispatch, no context, no atoms, no molecules, no quantum mechanics.
Just a variable. Holding a value. Like variables have done since the dawn of programming.
Those were simpler times.