Insane Defaults: React & useState()

When I first started learning React, it was pretty straightforward to throw some JSX elements together, tie them up with some rendering logic, and go on my way. But of course, it didn't take long for me to start needing state that persisted across re-renders. The advice I got on dealing with this was “just use useState”.

Really though, useState is kind of a misnomer (as is useRef). There are a lot of hooks in React that can give you "state". They just have different semantics. useState isn't for state, it's for state which impacts your render.

For some reason, most guides and tutorials you'll find online will not explain this to you properly. Instead, you'll get a huge amount of (probably AI-generated) filler about setting up a project first, then a single naive and possibly wrong example of useState, and then some text that restates what the example does.

As a web developer, what's the solution this? It's simple. Just read the god damn documentation. It's not that hard. The docs are well-written, reasonably complete, concise, and include multiple realistic and straightforward examples. Ten minutes to read this page may save you an hour of frantically googling around to figure out why your initial state function is getting called twice.

As if to prove my point, I spent 40 minutes while I was writing this trying to figure out when making a field readonly in JavaScript became possible. Instead I found so many of these completely useless articles that just explain stuff already on MDN except with less precision, in more words, and often incorrectly. ChatGPT is overtaking search engine results because ChatGPT reads the fucking docs.

But really, I'm not here to complain about the state of the internet. Or the state of modern web developers. I'm here to complain about the state of useState().

Putting a value inside of useState is like spilling ice cream on your shirt. From one perspective, you've lost the ice cream. From another perspective though, you're just saving it for later. Also, much like the spilled ice cream, it's certainly possible to clean it up (modify the variable directly), but really, you should probably just change shirts (wait for a re-render).

God, I love torturing metaphors. I should probably start going to therapy.

Anyway, the recommended pattern for useState looks a little something like this:

const [iceCream, setIceCream] = useState<boolean>(false);
// do something with the ice cream status here

With this example, things look good. The following code doesn't work:

// iceCream = false;
// ~~~~~~~~~~~~~~~~~ Cannot assign to 'iceCream' because it is const

This is good. Changing the variable like this without going through the setter won't trigger a re-render, and your component won't update properly. That's unexpected behavior, so preventing you from doing it is good. Confusing code is bad, and bad code shouldn't work. However, this protection is only due to our const declaration of these values. Consider the following example:

let [iceCream, setIceCream] = useState<boolean>(false);
iceCream = true; // uh oh

This is perfectly acceptable, according to the compiler. In fact, popular linters do not even check for this. The only reason this isn't a common problem is because we've successfully beat into everyone's head that you should always use const. Even the garbage AI examples have picked up on this pattern because it's so universal. That's not all, either. This example works, too:

const [chocoIceCreamShop, setChocoIceCreamShop] = useState<StoreOffering>({ iceCreams: ["chocolate"] });
iceCreamShop.iceCreams = ["vanilla"]; // !!!

This is bad. You can mutate the contents of objects that are marked const, so there's absolutely no protection against you doing something like this. const only prevents you from re-assigning the variable itself.

I chalk this up to React having historically incomplete support for TypeScript. From a pure JavaScript perspective, there's really nothing more you can reasonably do here. You could do some checks and try to call Object.freeze() on the object, but this is highly problematic for several reasons. First and foremost, there's a performance penalty.

Operation10K iterations100K iterations1M iterations
Declare an empty object

(negligible)

(negligible)

3ms
Declare an empty object, then freeze it(negligible)5ms41ms
Create an object with 10 randomly-generated string properties100±30ms900±100ms7,800±200ms

Create an object with 10 random string properties and freeze

80±2ms750±50ms8,000±200ms

These numbers come from a quick and dirty benchmark I ran directly in Chrome. You could benchmark this in Node, but since we're discussing React here, browser performance seems far more relevant. The numbers are difficult to make sense of at first. For the minimal example (empty), freeze is significantly slower. This makes sense, because freeze does something, and the fastest code is code which does nothing.

However, as you add properties, freeze becomes faster and faster. I ran more tests not included in the above table. One of those tests was creating an object with 10 string properties that I defined ahead of time, instead of random ones. This was, of course, significantly faster than the random test. However, for Object.freeze, the 10 fixed-property test was faster than the empty object test (at a million iterations, 41ms became 26ms).

Now, I could dive even deeper into this, and engineer ways to defeat the optimizations V8 does, but I think that misses the point. We're concerned about real-world performance. In the real world, most of these optimizations will apply, so we should consider them in our benchmark.

My assumption is that if you're using an object in useState , it's most likely not changing shape randomly. It's also most likely either small or not being updated frequently. That means the most relevant benchmarks on the table are up and to the left. I'd be willing to bet that in real-world scenarios, the performance hit of freezing objects is enough that it would noticeably impact the experience for slow devices on some websites. And for what benefit, exactly? This takes us to the next problem with the freeze idea.

Object.freeze doesn't actually make the behavior less confusing.

TypeScript is not aware of Object.freeze and will happily let you try to mutate a frozen object. That means the behavior in this case falls back to JavaScript's runtime behavior. So what is JavaScript's runtime behavior?

const obj = Object.freeze({ a: [1, 2, 3] });
obj.a = [4, 5, 6];
// --> (3) [4, 5, 6]
obj.a;
// --> (3) [1, 2, 3]

Yeah, that's right. JavaScript simply pretends it succeeded and continues on its way without actually doing anything. Things behave a little better in strict mode, throwing an exception. But that doesn't stop you from writing bad code, it stops you from running bad code. And there's another problem with Object.freeze left to consider: It's not recursive.

const obj = Object.freeze({ a: { b: 1 } });
obj.a.b = 2;
// --> 2
obj.a.b;
// --> 2

So, I think it's clear at this point, Object.freeze sucks. What we want is something that behaves in this fast-but-unsafe manner at runtime, and in the slower-but-saner method at compile time. Well, great news! This is possible in TypeScript. Behold:

const [chocoIceCreamShop, setChocoIceCreamShop] = useState<Readonly<StoreOffering>>({
	iceCreams: ["chocolate"]
});
// chocoIceCreamShop.iceCreams = ["vanilla"];
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Cannot assign to 'iceCreams' because it is a read-only property.

In TypeScript, type casts have no effect at runtime. So, we can simply tell the compiler to treat this as though it were frozen, and still get the nice and fast behavior at runtime. This is better, but it's kind of a mouthful. It also means you have to manually specify the type for useState every time, and it's not recursive. Really, what we should do is replace useState with our own version that forces this cast to happen.

/// Source: https://rozalily.dev/blog/insane-defaults/usestate
import React from "react";
import { Dispatch, SetStateAction } from "react";

/**
 * Returns a stateful value, and a function to update it.
 * Safely prevents mutating the state object.
 */
export function useState<T>(v: T | (() => T)) {
	return React.useState<Readonly<T>>(v);
}

Note: Doing this may or may not actually be a good idea.

To make properties readonly recursively, there are potential solutions for recursion in this StackOverflow page but your mileage may vary. I'm wary of solutions that depend on extending the TypeScript typings, as there's usually a good reason these utilities don't already exist in TypeScript. At some point it's worth taking a step back and asking yourself if this is a real problem or just a skill issue. It's usually better to solve 80% of the problem using 20% of the code, and make up for the difference in experience.

So, why isn't this the default behavior? It seems strictly better, in any reasonable scenario. Apart from the fact you really ought to be using useReducer instead, it seems like it can only benefit developers to add this extra level of enforcement.

Warning: Rant ahead. The useful part of this article is over.

Something you really need to understand about TypeScript is that it didn't always exist. It came out in 2012. This may come as a surprise to you, but many websites existed prior to 2012. And post-2012, when TypeScript was available, these websites continued to exist.

TypeScript technically predates React. But TypeScript's relevance doesn't. The web landscape of 2013 looked nothing like it does today. If you wanted to use TypeScript in 2013, there was one IDE that supported it. Visual Studio. Not VSCode. VSCode didn't exist until 2015. Visual freakin' Studio.

Typescript's early life was heavily based on the proposal for ECMAScript 2015. This is notable, because ECMAScript 2015, as the name implies, came out after 2013. Most of the features of TypeScript were considered highly experimental at the time, and nobody knew if this was going to make it as a long-lasting tool, or if it'd be yet another girl-of-the-week web technology.

TypeScript didn't truly start to be useful until around 2017. By that point, React was already the dominant frontend framework. And being a dominant frontend framework, it had a lot of code written for it. And that code was written in JavaScript, by JavaScript programmers, who were accustomed to doing things in a JavaScript way.

All of this is to say, I don't blame React for its TypeScript-based shortcomings. It's an unfortunate consequence of living in the land of real-world users. Sometimes you make a series of objectively correct decisions and end up with a mess.

This behavior could easily be changed. The patch above could simply be what React exports as useState moving forward. It'd be a breaking change, but it would hardly be the first. Node has a great feature called "package versioning". You can just not upgrade React if you don't want to. You can keep using class components. You can keep writing your 2015 React code with okSoMaybeComponentDidMountButSoWhat(). At some point you need to acknowledge that you've got a legacy platform and you're not going to keep getting shiny new features that play nice with your three-presidents-ago codebase.

The real reason we won't see this feature is just as soul-crushing, though. When React first started, it was, like many great technologies, highly opinionated. There was a React way of doing things and that was that. Get with it or get out of the way. But as time passes, React grows soft in its age. Features that don't match the ideology of React are added, because enough people want them. Eventually React becomes the very status quo it once rebelled against.

Today, if you want to make a change like this, you need a strong argument. Eventually you'll need a perfect one. And because there are some reasonable-sounding arguments why readonly-by-default could be undesirable, that moves this idea from "why not" to "why". As we build more and more layers atop existing tools, breaking changes become increasingly unworkable. Even if you rolled them out, so few people would have the ability to update that they might as well have never released.

Eventually there are so many of these "nice but not worth the risk" ideas that another framework better-positioned to innovate will come and take over. Or, in React's case, they add another optional "mode" that's off by default to contain new features and checks. Meanwhile the defaults continue to drift away from sanity.

Because come on. Who isn't using a non-default package manager, a bundler, a transpiler (or two), a linter, and maybe a different runtime entirely? We support, and frequently demand all kinds of tools. Because writing a project in vanilla NodeJS? Just npm i and node .? Seriously. Who does that?