The items in const [items, setItems] = useState([]) is the state as of the last render. If setItems([...items, item]) runs after an await, in a timer, or twice before the next render, items is out of date and the other update is lost. A function gets the current state: setItems(previous => [...previous, item]).
The rule reports a setter that's called with an array or object literal that spreads its own state.
Configure
Off by default. To turn it on:
[lint.rules]
"bun/react-prefer-updater-function" = "error" # or "warn"
Examples
Reported:
import { useState } from "react";
function Cart() {
const [items, setItems] = useState([]);
async function add(id) {
const item = await loadItem(id);
setItems([...items, item]);
}
// ...
}
import { useState } from "react";
function Profile() {
const [form, setForm] = useState({ name: "", email: "" });
return <input value={form.name} onChange={event => setForm({ ...form, name: event.target.value })} />;
}
Not reported:
import { useState } from "react";
function Cart() {
const [items, setItems] = useState([]);
async function add(id) {
const item = await loadItem(id);
setItems(previous => [...previous, item]);
}
// ...
}
If neither name comes from a useState() in the file, for example because both are props, the rule goes by the names. setRows is treated as the setter for rows:
function AddRow({ rows, setRows }) {
return <button onClick={() => setRows([...rows, { id: rows.length }])}>Add</button>;
}
Spreading something else is fine:
import { useState } from "react";
function Search({ defaults }) {
const [filters, setFilters] = useState({});
return <button onClick={() => setFilters({ ...defaults })}>Reset</button>;
}