The Mental Model
Your browser has a whiteboard.
Every time a user visits your app, the browser opens a blank whiteboard. State is what you write on it. When the user refreshes or closes the tab, the whiteboard gets erased.
A value that React watches. When it changes, the screen re-renders.
Code that runs at specific moments: on load, on change, on cleanup.
Data flowing from parent to child. One-way. Read-only to the receiver.
State only lives in memory. Refresh kills it unless you persist it.
useState
How It Works
useState creates a variable that React watches. When you call the setter function, React re-renders the component with the new value.
const [count, setCount] = useState(0);
// count = the value
// setCount = the updater
// 0 = the initial value
setCount(count + 1);
// React re-renders the component
The Rules of State
Never modify state directly
count = 5 is wrong. Always use the setter: setCount(5)
State updates are asynchronous
Calling setCount(1) doesn't change count immediately. It schedules a re-render.
Each component has its own state
Two copies of the same component don't share state. Each has its own sticky note.
useEffect
Run code at the right moment.
useEffect lets you run code after React renders. It's how you fetch data, set up listeners, or start timers.
Think of it as setting an alarm: "When this component appears, do this thing." And optionally: "When it disappears, clean up."
Runs once on mount (page load)
Runs every time count changes
Cleanup: runs when component unmounts
useEffect(() => {
// This runs AFTER render
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// Cleanup: stop the timer
return () => clearInterval(timer);
}, []); // ← empty = run once
Server vs Screen
Not all data is the same. The trick is knowing where each type lives.
Server Data
Lives in the database. Survives refreshes. Shared across all users.
Screen State
Lives in the browser. Dies on refresh. Only for this user, this tab.
The Persistence Spectrum
Dies on refresh→LocalStorage
Survives refresh→Database
Survives everything
Common Patterns
✗ State Traps
"I'll just use a global variable"
React won't know it changed. No re-render. Use useState.
"I set state but it didn't change"
State updates are batched and async. The new value appears on the next render.
"My useEffect runs twice"
React Strict Mode runs effects twice in dev. It's intentional. Your production code runs once.
"Infinite re-render loop"
Setting state inside useEffect without a dependency array = infinite loop. Always add deps.
✓ Clean State
Keep state close to where it's used
If only one component needs it, put the state there. Don't lift it unless you have to.
Derive values instead of storing them
If you can calculate it from existing state, don't create new state. const total = items.length
Use the function form for updates
setCount(c => c + 1) is safer than setCount(count + 1) in async code.
Always clean up effects
Return a cleanup function from useEffect. Stop timers, remove listeners, cancel requests.
The Exercises
Make your app remember things.
The Counter
useState Basics
- →Create a counter with + and - buttons
- →Display the count value
- →Add a reset button
The Toggle
Conditional Rendering
- →Create a dark mode toggle
- →Switch background/text colors on click
- →Observe: it resets on refresh
The Timer
useEffect + Cleanup
- →Build a stopwatch with useEffect
- →Add start/stop/reset controls
- →Return a cleanup function
📋 Quick Reference Cheatsheet
State
const [x, setX] = useState(0)Effect (on mount)
useEffect(() => { }, [])Cleanup
return () => clearInterval(id)