Skip to content

[rule] prefer-set-state-callback for object type value #1289

@imteammy

Description

@imteammy

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions