Why a variable isn't enough: state and re-rendering
February 9, 2022 · 5 min read
The first thing that confused me about React: I changed a variable inside a component, logged it, saw the new value, and the screen didn't move. It took me a while to see why, and once I did, most of React made more sense.
React draws once
An app starts from a single index.html with an empty div id="root". React takes your App component, runs it, runs every component inside it, and turns the result into real DOM inside that div. Components are just functions, and React calls them.
Then it stops. It doesn't keep re-running your functions in case something changed. So when you write title = "new", nothing tells React to look again. The variable changes, the screen doesn't.
State is how you ask for another run
useState gives you a value that React keeps for you, and a function to change it. Calling that function is the signal: this component's data changed, run it again. React re-runs the component, compares the result with last time, and only touches the DOM where something actually differs.
const [title, setTitle] = useState(props.title);
const clickHandler = () => {
setTitle("Updated!");
};That's also why it's a const. You never assign to title yourself; React hands you a fresh one every time the component runs. The argument to useState is only used the very first time. On every later run, React ignores it and gives you the latest value it's holding.
The update isn't instant
A gotcha that caught me more than once:
setTitle("Updated!");
console.log(title); // still the old titlesetTitle doesn't change anything on the spot. It schedules a re-run, and the new value arrives with it. Code on the next line still sees the snapshot from this run.
If the next value depends on the last one, pass a function
For forms I first kept everything in one object and spread the old one in. It works, but it reads the userInput from this run, which can be stale if several updates are queued:
// works, but can use an outdated snapshot
setUserInput({ ...userInput, enteredTitle: e.target.value });
// safe: React passes in the latest state, after every queued update
setUserInput((prev) => ({ ...prev, enteredTitle: e.target.value }));The rule I wrote down: whenever the new state is calculated from the old state, use the function form. A counter, a toggle, a merged object, all the same.
Two-way binding
Inputs always hand you strings, which is why form state usually starts as "". Listening to onChange keeps state in sync with the input. Feeding state back through value makes it work the other way too, so clearing the state clears the field:
<input
type="text"
value={enteredTitle}
onChange={(e) => setEnteredTitle(e.target.value)}
/>After submit, setEnteredTitle("") empties the input. Forms submit by reloading the page by default, so the submit handler starts with e.preventDefault().
What stuck with me: a component is a function React calls when it has a reason to. State is how you give it one.