3 / 3
Don't use spread for state transitions
Types stop you from writing what shouldn't be there. They don't stop you from dragging along what already was.
After switching to a union
The team relaxed. Impossible states were gone.
The code that changed state was still written out of habit. Keep the existing values, overwrite only what changes. Short, easy to read, and it passed the type check.
setState({ ...state, status: "error", error: e });A few weeks later
The team decided to add an error reporting tool. "When we hit the error state, send the whole state. It'll make debugging easier."
The same week, someone else updated the header: if there's data, show the user's name.
if (state.status === "error") {
report(state);
}if ("data" in state) {
showUserName(state.data.name);
}Then one day
Someone reading the logs noticed something odd. Every error log carried the previous user's personal information, all of it.
The same day, a user who hit an error while switching accounts saw the previous account's name in the header.
{
status: "error",
error: e,
data: previousUser,
}Why it happened
Spread copies every field from the previous state. And TypeScript doesn't check extra fields that arrive through a spread.
What the union prevented was writing data into the error state. It couldn't stop data that was already there from tagging along.
Before it breaks
Give each state its own transition function, and change state only through those functions. Each one builds a new object from just the fields it needs, so nothing gets dragged along.
"Keep what's there and change just this" is also one of the habits AI falls into most often. Taking away the way to write it is cheaper than catching it in review every time.
const toLoading = (): State => ({ status: "loading" });
const toSuccess = (data: User): State => ({ status: "success", data });
const toError = (error: Error): State => ({ status: "error", error });
setState(toError(e));