-
-
Notifications
You must be signed in to change notification settings - Fork 31
Description
Problem Description
In React, when using useState to manage object-type values, developers often update the state without using the callback form of the setter function. This can lead to bugs when the update depends on the previous state, especially within asynchronous operations, effects, or event handlers.
For example, directly setting state like:
setUser({ ...user, age: 30 });
setItems([...items, newItem]);can lead to stale state issues if multiple updates are queued or if user is outdated due to closures. React provides a callback form for setState to ensure updates are based on the latest state:
setUser(prev => ({ ...prev, age: 30 }));
setItems(prev => [...prev, newItem]);
// allow empty object
setUser({});
setItems([]); However, this best practice is often forgotten or inconsistently applied, especially when dealing with objects.
Alternative Solutions
-
Documentation: Developers can be educated to always use the callback form. But this relies on discipline and code reviews.
-
TypeScript Linting: TypeScript can help to some extent, but it doesn't warn against non-callback usage of setState.
-
Custom ESLint Rule (Proposed): Enforce the callback usage for object-type state setters in useState, improving code reliability automatically.
Rule Name and Error Message
Rule Name: prefer-set-state-callback
Error Message:
"Avoid setting object-type state directly. Use the callback form of setState to ensure you update based on the latest state."
Detail:
This rule triggers when an object-type state (e.g., from useState({})) is updated using the direct form instead of the callback form.
Examples
❌ Incorrect
const [user, setUser] = useState({ name: 'John', age: 25 });
const [items, setItems] = useState(['a', 'b']);
function updateAge() {
setUser({ ...user, age: 30 }); // ❌ Triggers rule
const newUser = { ...user, }
setUser(newUser); // ❌ Triggers rule
}
function addItem(item: string) {
setItems([...items, item]); // ❌ Triggers rule
const newItems = [...items]
setItems([...items, item]); // ❌ Triggers rule
}✅ Correct
const [user, setUser] = useState({ name: 'John', age: 25 });
const [items, setItems] = useState([{ id: 1, value: "a"}, { id: 2, value : "b" } ]);
function updateAge() {
setUser(prev => ({ ...prev, age: 30 })); // ✅ Correct usage
}
function resetUser() {
setUser({}); // ✅ Correct usage
setUser({ name:"",age: 0 }); // ✅ Correct usage
}
function addItem(item: string) {
setItems((items)=>[...items, {id:Date.now(), value: item }]); // ✅ Correct usage
}
function resetItem() {
setItems([]) // ✅ Correct usage
setItems([{ id:0 , value:"" }]) // ✅ Correct usage
}Extra 🧪 for primitive value
const [count,setCount] = useState(0)
const [open,setOpen] = useState(false)
const toggle = () => setOpen(!open) // ❌ Triggers rule
const toggle = () => setOpen(open=>!open) ✅ Correct usage
const countActions = ()=> {
setCount(count +1) // ❌ Triggers rule
setCount(count - 1) // ❌ Triggers rule
setCount(count=>count + 1) ✅ Correct usage
setCount(count=>count - 1) ✅ Correct usage
}Evaluation Checklist
- I have had problems with the pattern I want to forbid
- I could not find a way to solve the problem by changing the API of the problematic code or introducing a new API
- I have thought very hard about what the corner cases could be and what kind of patterns this would forbid that are actually okay, and they are acceptable
- I think the rule explains well enough how to solve the issue, to make sure beginners are not blocked by it
- I have discussed this rule with team members, and they all find it valuable