← All writing
Craft · · 7 min

One letter behind

On reading state right after setting it, what an empty dependency array is actually promising, and the one component I still can't write as a function.

React JavaScript

Type marc into the staff directory I’d just rewritten and you got everyone matching mar. Clear the field completely and the results for m stayed on screen, refusing to leave.

I’d converted that component to hooks the week React 16.8 came out, so I did the reasonable thing and blamed the new feature.

I blamed React first

It’s a small thing. A search field, a list of about 60 people, filter as you type, roughly 400 lines once you count the empty state and the keyboard handling. It’s the only React on an otherwise static build, which is how nearly all the React in my life arrives.

staff-directory.js
const [query, setQuery] = useState('');
const [results, setResults] = useState(people);

function onChange(event) {
  setQuery(event.target.value);
  setResults(people.filter(person => matches(person, query)));
}
JavaScript

setQuery doesn’t reach into the variable called query and change it, because there is no such variable to change. It’s a const, handed to this particular run of the function. It holds that value until the run is over and thrown away. So the filter on the next line reads the previous search, every time, forever.

Which is a bug I’ve written before many times going back to my very first React component. this.setState is asynchronous, so reading this.state.query on the line underneath it hands you the old value, and every React developer I know learns that, forgets it, and relearns it about once a year.

The whole pitch was that hooks would save me from this. Two weeks in, my first hooks bug was a this bug with the this taken out.

Every render gets its own everything

A class component is one object. It gets created once, it sticks around for as long as the thing is mounted, and this.state is a property on it that you keep overwriting. So when you picture a class component you’re picturing an object that exists and changes over time, which is a fair picture, because that is exactly what it is.

A function component isn’t that. It’s a function that gets called again, top to bottom, every single time anything changes, and each of those calls gets its own props, its own state values, its own handlers, and its own closures over all of it. Nothing inside a render ever changes. The function just runs again with different values. A render is a photograph, not a live feed.

Once that lands, the fix stops being a fix and turns into a deletion.

const [query, setQuery] = useState('');
const results = people.filter(person => matches(person, query));

function onChange(event) {
  setQuery(event.target.value);
}
JavaScript

results never needed to be state. It’s people filtered by query, so it can be worked out during the render, and a value you work out during the render is a value that can’t fall out of step with anything. I can’t help but notice how many of my state bugs over the years have been two pieces of state that were required to agree with each other, and how often the answer was that one of them wasn’t state.

The empty array has a long memory

The second symptom took much longer to find, because it didn’t look like anything.

The client wanted the search terms logged. So I wrote a debounced effect for it and gave it an empty dependency array, because I wanted the thing set up once, the way componentDidMount sets things up once.

staff-directory.js
// runs once, logs the empty string, never runs again
useEffect(() => {
  const id = setTimeout(() => logSearch(query), 500);
  return () => clearTimeout(id);
}, []);
JavaScript

It logged the empty string, on mount, and then sat there quietly for the rest of the session. Nothing warned me. Closing over a variable and keeping it isn’t an error, it’s what closures are for.

Everybody reads [] as “run this on mount,” because that’s the translation from the lifecycle method and it’s how the array gets introduced to you. It actually says which values the effect is allowed to see, and an empty array says none of them, ever again. Those are two different promises, and only one of them is written down anywhere in the code.

The lint rule fills the array in for you and I wouldn’t write a second component without it. I’m a little wary of a rule I need a linter to follow though, and it sits right next to the other one. Hooks are matched up by the order you call them in, so no conditionals, no loops, no early return above one, and nothing in the language enforces any of that. A wart at best, and at worst a piece of hidden state inside something that looks like an ordinary function call.

The counter is that the rules are few, the linter catches them, and two weeks was enough for me to stop thinking about them. Fair enough. I’d just say (1) the linter is carrying more of this than the pitch let on, and (2) “you get used to it” was also the answer to this.

Class dismissed

Except I haven’t dismissed any.

There’s no hook for componentDidCatch and no sign that one is coming, so the component on that project that catches a thrown error and renders something other than a blank page is a class and is going to stay a class. I find that oddly reassuring. It’s a limit that has nothing to do with taste, in an argument that is otherwise almost entirely about taste.

The rest of my class components are the rest of the argument. They’re in production, they work, nobody has ever complained about one, and rewriting them buys me a diff to review and a few more chances to write the bug I’ve just spent this whole post describing. The genuinely new thing here is the custom hook, a plain function that calls other hooks and shares stateful logic between components without adding a component to the tree. That’s the part higher-order components and render props could never quite manage without leaving a stack of wrappers in the DevTools inspector. I can write custom hooks alongside the classes without touching a single one of them.

So the bar for reaching for any of this hasn’t really moved for me. A function with two hooks is a lot less apparatus than a constructor and three lifecycle methods, sure, but the download is the same size it was in January, and the size was always the argument. On the projects where React already earns its place, the small components are nicer to write than they were.

Two weeks, one component, so grain of salt and all that. I keep turning over how little of this had to do with the syntax. For as long as I’ve been writing React, a component has been a thing that exists and changes over time, and hooks quietly swapped that picture out for a stack of photographs while I read the release notes and nodded along. Both of the mistakes I made that week came out of the old picture instead of the new API, which is not the sort of thing a linter is ever going to find for me. I’m not rewriting anything. I’m going to write the next new component this way, leave the old ones where they are, and find out what else that picture was quietly doing for me.

Read similar posts
2 min

Intl.NumberFormat

Currency formatting is in the browser, has been for years, and is still being done with a hardcoded dollar sign and a call to toFixed in most of the code I read.

2 min

AbortController

The thing everybody knows as the way to cancel a fetch is also the tidiest way to remove a pile of event listeners, and the second use has changed more of my code than the first.