DEV Community

Jade Banks
Jade Banks

Posted on

React: Use 'Excess Property Checks' in setState() to avoid subtle bugs

TL;DR

When using objects as state, annotate the return type on callbacks passed to setState()
e.g. Instead of setState(state => do setState((state): State =>

Overview

The Spread Operator is a JavaScript feature, it is particularly relevant however to React because State Objects should not be mutated.

Here we see a common pattern in the following code (parts omitted for clarity)

// Define app/component state
type State = {
  name: string,
  count: number,
}

// Declare initial state
const [state, setState] = useState<State>({ name: 'Unknown', count: 0 });

// Partial state updates made like this
function resetCount() {
  setState(state => ({
    ...state,
    count: 0,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Problem

Consider this code for setting the count property back to zero, while preserving the values of the other properties.

// This code doesn't trigger any errors, despite the mistake
setState(state => ({
  ...state,
  cont: 0, // typo of `count`
}));
Enter fullscreen mode Exit fullscreen mode

The issue is the typo will not be picked up by the type checker. This is because the spread operator has given us a fully formed object of the State type, the incorrectly spelt cont is considered an additional (excess) property. The type checker is considering this valid.

Due to IntelliSense/Auto complete, it's not likely this would happen when writing new code but can occur more subtly when renaming.

If we decide, for example, after some time, that count should be personCount. Many references to the old name will be flagged in the IDE as errors, prompting us to update them. This won't be the case for setState() calls though. Not at build nor run time. We're now setting a property that doesn't exist in the State type (count) and the target property (personCount) keeps it's existing value. This refactor could introduce a breaking change.

There is a good chance this would not be picked up in code review either, due to the nature of code reviews focussing on the changed lines, and this error being on an unchanged line.

How can we fix this

Using the IDE Refactor > Rename feature would give us better coverage of the rename but we cannot be expected to remember this every time.

We want to force TypeScript to apply Excess Property Checks. This can be done by simply setting a return type on the function passed to setState(). In other words, we change state => to (state): State => and it would look like this

type State = {
  name: string,
  personCount: number, // Renamed from `count`
}

const [state, setState] = useState<State>({ name: 'Unknown', personCount: 0 });

// IDE error shown (as desired) saying that 'count' is not a known property
function resetCount() {
  setState((state): State => ({ // This line changed to include return type
    ...state,
    count: 0,
  }));
}
Enter fullscreen mode Exit fullscreen mode

By specifying the return type explicitly, we now get build time checking on the properties we specify and can be more confident our code is what we intend.

useReducer()

The normal approach for complex state structures is to use useReducer() instead.

The same advice applies here, make sure to explicitly set the return type

// ✘ - Relying on inferred return type won't check excess properties
function reducer(state: State, action: Action) {
  ...
}

// ✔ - Setting the return type helps catch incorrect properties
function reducer(state: State, action: Action): State {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Other Approaches

Immer library

The Immer library provides a proxy object to do in place updates. We don't need to re-create objects ourselves, so can rely on TypeScript checking that the properties exist.

Use a 'Helper' function

I wouldn't suggest this as a general approach as it's not idiomatic React and may make the code harder to understand for other developers.

However it's worth showing as the Partial<Type> Utility Type can be very useful

// Helper function
function update(partial: Partial<State>) {
  setState(s => ({
    ...s,
    ...partial
  }));
}

function resetCount() {
  update({ cont: 0 }); // Error: 'cont' correctly flagged as not existing
}
Enter fullscreen mode Exit fullscreen mode

Finally

You can read the Type Compatibility documentation if you want to learn in more detail about this and the reasoning behind the design decisions.

If you use AI tools and the code it generates doesn't follow the suggested fix automatically, then add this into your agents/rules file, giving it the correct and incorrect forms as an example.

Top comments (0)