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.

VARIABLEtitle = 'new'nothing happensReact never re-runsSTATEsetTitle('new')component runs againlatest snapshotDOM updatedonly what changed
Changing a variable is invisible to React. Calling a state setter schedules another run of the component.

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 title
setTitle schedules an update. The new value only exists in the next run of the component.

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