Teaching AI Agents to Batch React State Updates with ESLint
Learn how to prevent AI coding assistants from creating useState render waterfalls by combining custom ESLint rules with Claude instructions for automatic useImmer enforcement.
While I was reviewing some React components generated by Claude the other day, I noticed a pattern that made me pause. The AI had created a perfectly functional settings panel with validation, loading states, error handling, and auto-save—but it used seven separate useState calls. On the surface, everything worked. But I knew this was a performance time bomb waiting to explode.
Little did I know, this discovery would lead me down a rabbit hole of teaching AI agents to write better React code automatically. In this post, I'll show you how to prevent AI coding assistants from creating useState render waterfalls by combining custom ESLint rules with Claude-specific instructions.
By the end, you'll have a copy-paste ESLint rule and a Claude configuration that enforces useImmer for batched state updates—catching the problem at lint time and teaching your AI assistant to use better patterns from the start.
1. The Problem: When AI Agents Generate useState Waterfalls
Here's what the AI-generated component looked like:
// ❌ AI-generated component with multiple useState calls
function SettingsPanel() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const handleSave = async () => {
setSaveStatus('saving')
setErrorMessage(null)
setIsDirty(false)
try {
await api.saveSettings({ email, password })
setSaveStatus('saved')
setLastSaved(new Date())
} catch (error) {
setSaveStatus('error')
setErrorMessage(error.message)
}
}
// Component JSX...
}The problem? In handleSave(), we're calling four separate state setters in rapid succession. Even though React 18 introduced automatic batching, there are edge cases where this can still trigger multiple renders—especially in async callbacks, effects, and event handlers with complex timing.
I cannot stress this enough: each setState call is a potential render cycle. Seven pieces of state means seven opportunities for React to decide it needs to re-render your component tree.
2. Understanding React Render Batching (And Its Limits)
Let's look at what's actually happening under the hood:
// ❌ Demonstrating the render waterfall
function ProblematicComponent() {
const [count, setCount] = useState(0)
const [status, setStatus] = useState('idle')
const [data, setData] = useState(null)
useEffect(() => {
console.log('Component rendered!')
})
const fetchData = async () => {
console.log('Starting fetch...')
setStatus('loading') // Render #1
setCount(c => c + 1) // Render #2
const result = await api.fetch()
setData(result) // Render #3 (after Promise resolves)
setStatus('success') // Render #4
}
return (
<div>
<button onClick={fetchData}>Fetch</button>
<p>Renders: {count}</p>
<p>Status: {status}</p>
</div>
)
}When you click the button, the console shows:
Starting fetch...
Component rendered! // After setStatus('loading')
Component rendered! // After setCount(c => c + 1)
Component rendered! // After setData(result)
Component rendered! // After setStatus('success')
React 18's automatic batching helps, but it's not magic. Updates in async callbacks (like after await) are not automatically batched together. Each state setter after the Promise resolves triggers its own render.
In other words, more state variables = more chances for render waterfalls. When I came across this in the AI-generated code, I realized we needed a systematic solution.

3. The Solution: useImmer for Batched Updates
Luckily we can consolidate all related state into a single object and use useImmer from the use-immer package. This gives us two massive benefits:
- Single state object = single render cycle (one
updateState()call, one render) - Immutable updates without spread operator hell (Immer handles the immutability)
Here's the refactored version:
// ✅ Refactored with useImmer - single state object
import { useImmer } from 'use-immer'
function SettingsPanel() {
const [state, updateState] = useImmer({
email: '',
password: '',
confirmPassword: '',
saveStatus: 'idle' as 'idle' | 'saving' | 'saved' | 'error',
errorMessage: null as string | null,
isDirty: false,
lastSaved: null as Date | null,
})
const handleSave = async () => {
// Batch all pre-save updates into ONE render
updateState((draft) => {
draft.saveStatus = 'saving'
draft.errorMessage = null
draft.isDirty = false
})
try {
await api.saveSettings({
email: state.email,
password: state.password
})
// Batch all post-save updates into ONE render
updateState((draft) => {
draft.saveStatus = 'saved'
draft.lastSaved = new Date()
})
} catch (error) {
// Single render for error state
updateState((draft) => {
draft.saveStatus = 'error'
draft.errorMessage = error.message
})
}
}
// Component JSX...
}What changed?
- Seven
useStatecalls → OneuseImmercall - Four separate setState calls in
handleSave→ Three batchedupdateStatecalls (pre-save, success, error) - Potential 4+ renders → Maximum 3 renders (and usually just 2)
The beauty of Immer is that you write code that looks like you're mutating the state (draft.saveStatus = 'saving'), but Immer produces a new immutable object behind the scenes. No more spread operator pyramids!
4. Advanced useImmer Patterns with Lodash
For complex state with nested objects, you can combine useImmer with lodash utilities for even cleaner updates:
// ✅ Advanced useImmer with lodash helpers
import { useImmer } from 'use-immer'
import get from 'lodash/get.js'
import set from 'lodash/set.js'
import assign from 'lodash/assign.js'
function ComplexForm() {
const [state, updateState] = useImmer({
user: {
profile: {
firstName: '',
lastName: '',
email: '',
},
settings: {
theme: 'light',
notifications: true,
}
},
meta: {
isDirty: false,
lastSaved: null,
}
})
// Pattern #1: Multiple shallow updates in one line
const handleMetaUpdate = () => {
updateState((draft) => void assign(draft.meta, {
isDirty: true,
lastSaved: new Date()
}))
}
// Pattern #2: Deep nested updates
const handleEmailChange = (email: string) => {
updateState((draft) => set(draft, 'user.profile.email', email))
}
// Pattern #3: Safe nested reads
const userEmail = get(state, 'user.profile.email', '')
const isDirty = get(state, 'meta.isDirty', false)
return <div>{/* Form JSX */}</div>
}Why this matters: When AI agents generate forms or complex UIs, they often create separate state variables for every field. With this pattern, you can have deeply nested state that updates atomically—all with a single render.
5. Building the ESLint Rule: Catching the Pattern Automatically
Now here's where it gets interesting. We can teach ESLint to detect when developers (or AI agents) are using multiple useState calls that should be consolidated into useImmer.
First, let's configure ESLint to enable our custom rule:
// .eslintrc.json
{
"plugins": ["@your-org/eslint-plugin"],
"rules": {
"@your-org/use-immer-state": ["warn", {
"minProperties": 3,
"minUseStateCount": 4,
"ignoreArrays": false,
"ignorePrimitiveArrays": true
}]
}
}What these options mean:
minProperties: 3- Warn ifuseStateis initialized with an object containing 3+ propertiesminUseStateCount: 4- Warn if a component has 4+useStatecallsignoreArrays: false- Don't ignore array stateignorePrimitiveArrays: true- But DO ignore primitive arrays likestring[]

6. Implementing the ESLint Rule: Detection Logic
Here's a simplified version of the ESLint rule to understand the core concept:
// Simplified ESLint visitor pattern
export const useImmerState = {
meta: {
type: 'suggestion',
messages: {
preferImmer: 'Consider using useImmer instead of useState for complex state objects.',
preferImmerMultiple: 'Multiple useState calls detected. Consider consolidating into useImmer.',
},
},
create(context) {
const scopeUseStateCount = new Map()
return {
CallExpression(node) {
// Only check useState calls
if (node.callee.name !== 'useState') return
// Track how many useState calls in this component
const functionScope = getFunctionScope(node)
const count = (scopeUseStateCount.get(functionScope) || 0) + 1
scopeUseStateCount.set(functionScope, count)
// Warn if too many useState in same component
if (count >= 4) {
context.report({
node,
messageId: 'preferImmerMultiple',
})
}
// Check if useState is initialized with a large object
const initialState = node.arguments[0]
if (initialState?.type === 'ObjectExpression') {
const propCount = initialState.properties.length
if (propCount >= 3) {
context.report({
node,
messageId: 'preferImmer',
})
}
}
},
}
},
}The detection strategy:
- Visit every function call in the AST
- Filter for
useStatecalls only - Track how many
useStatecalls exist in the current component scope - Check if
useStateis initialized with an object with 3+ properties - Report a warning when thresholds are exceeded
In other words, ESLint walks the abstract syntax tree (AST) of your code—think of it like a family tree of your code structure—and checks each useState call against our rules.
7. Full ESLint Rule Implementation
Here's the complete production-ready implementation with all edge cases handled:
// packages/eslint-plugin/src/rules/use-immer-state.js
/** @type {import('eslint').Rule.RuleModule} */
export const useImmerState = {
meta: {
type: 'suggestion',
docs: {
description: 'Suggest useImmer for complex state instead of useState with objects/arrays',
recommended: false,
},
messages: {
preferImmer: 'Consider using useImmer instead of useState for complex state objects. Import from "use-immer" package.',
preferImmerArray: 'Consider using useImmer instead of useState for array state. Import from "use-immer" package.',
preferImmerMultiple: 'Multiple related useState calls detected. Consider consolidating into a single useImmer state object.',
},
schema: [
{
type: 'object',
properties: {
minProperties: {
type: 'integer',
minimum: 1,
description: 'Minimum object properties to trigger warning (default: 3)',
},
minUseStateCount: {
type: 'integer',
minimum: 2,
description: 'Minimum useState calls in same scope to suggest consolidation (default: 4)',
},
ignoreArrays: {
type: 'boolean',
description: 'Whether to ignore array state (default: false)',
},
ignorePrimitiveArrays: {
type: 'boolean',
description: 'Ignore arrays of primitives like string[] (default: true)',
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] || {}
const minProperties = options.minProperties || 3
const minUseStateCount = options.minUseStateCount || 4
const ignoreArrays = options.ignoreArrays || false
const ignorePrimitiveArrays = options.ignorePrimitiveArrays !== false
const scopeUseStateCount = new Map()
function getFunctionScope(node) {
let current = node
while (current) {
if (
current.type === 'FunctionDeclaration' ||
current.type === 'FunctionExpression' ||
current.type === 'ArrowFunctionExpression'
) {
return current
}
current = current.parent
}
return null
}
function isPrimitiveArrayType(typeAnnotation) {
if (!typeAnnotation) return false
if (
typeAnnotation.type === 'TSTypeReference' &&
typeAnnotation.typeName.name === 'Array'
) {
const typeParam = typeAnnotation.typeParameters?.params?.[0]
if (typeParam && isPrimitiveType(typeParam)) return true
}
if (typeAnnotation.type === 'TSArrayType') {
return isPrimitiveType(typeAnnotation.elementType)
}
return false
}
function isPrimitiveType(typeNode) {
if (!typeNode) return false
const primitiveTypes = ['TSStringKeyword', 'TSNumberKeyword', 'TSBooleanKeyword']
return primitiveTypes.includes(typeNode.type)
}
function countObjectProperties(node) {
if (node.type !== 'ObjectExpression') return 0
return node.properties.filter(p => p.type === 'Property').length
}
function isArrayOfPrimitives(node) {
if (node.type !== 'ArrayExpression') return false
if (node.elements.length === 0) return true
return node.elements.every(el =>
el && (el.type === 'Literal' || el.type === 'Identifier')
)
}
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier' || node.callee.name !== 'useState') return
const scope = getFunctionScope(node)
if (scope) {
const count = (scopeUseStateCount.get(scope) || 0) + 1
scopeUseStateCount.set(scope, count)
if (count === minUseStateCount) {
context.report({ node, messageId: 'preferImmerMultiple' })
}
}
const args = node.arguments
if (args.length === 0) return
const initialState = args[0]
// Check for object state
if (initialState.type === 'ObjectExpression') {
const propCount = countObjectProperties(initialState)
if (propCount >= minProperties) {
context.report({ node, messageId: 'preferImmer' })
}
return
}
// Check for arrow function returning object (lazy init)
if (initialState.type === 'ArrowFunctionExpression') {
const body = initialState.body
if (body.type === 'ObjectExpression') {
const propCount = countObjectProperties(body)
if (propCount >= minProperties) {
context.report({ node, messageId: 'preferImmer' })
}
}
if (body.type === 'BlockStatement') {
const returnStmt = body.body.find(s => s.type === 'ReturnStatement')
if (returnStmt?.argument?.type === 'ObjectExpression') {
const propCount = countObjectProperties(returnStmt.argument)
if (propCount >= minProperties) {
context.report({ node, messageId: 'preferImmer' })
}
}
}
return
}
// Check for array state
if (!ignoreArrays && initialState.type === 'ArrayExpression') {
if (ignorePrimitiveArrays && isArrayOfPrimitives(initialState)) return
if (node.parent?.type === 'VariableDeclarator') {
const id = node.parent.id
if (id.typeAnnotation) {
const typeAnn = id.typeAnnotation.typeAnnotation
if (isPrimitiveArrayType(typeAnn)) return
}
}
if (initialState.elements.length > 0) {
const hasComplexElements = initialState.elements.some(el =>
el && (el.type === 'ObjectExpression' || el.type === 'ArrayExpression')
)
if (hasComplexElements) {
context.report({ node, messageId: 'preferImmerArray' })
}
}
}
},
}
},
}
export default useImmerStateThis rule handles:
- ✅ Multiple
useStatecalls in the same component - ✅
useStateinitialized with objects that have 3+ properties - ✅ Lazy initialization via arrow functions
- ✅ TypeScript type annotations
- ✅ Arrays of complex objects (but ignores primitive arrays)
- ✅ Configurable thresholds via ESLint config
8. Teaching Claude with Custom Rules
Here's where it gets really powerful. ESLint catches the problem after the code is written, but we can teach Claude to use the right pattern proactively by adding instructions to .claude/rules/code-style.md:
## Use useImmer for Complex State
**RULE:** Single `useImmer({ state })` + one `updateState()` = 1 render. Multiple `useState` = N renders. ESLint enforces automatically (`@your-org/use-immer-state: error`).
\```typescript
import get from 'lodash/get.js'
import set from 'lodash/set.js'
import assign from 'lodash/assign.js'
import { useImmer } from 'use-immer'
// ✅ CORRECT - Single state object with batched updates
const [state, updateState] = useImmer({
password: '',
saveStatus: 'idle',
errorMessage: null
})
// Batch multiple updates
updateState((draft) => {
draft.saveStatus = 'saving'
draft.errorMessage = null
})
// Batch multiple updates with lodash #1 (one-line multiple key update)
updateState((draft) => void assign(draft, {
saveStatus: 'saving',
errorMessage: null
}))
// Nested updates with lodash #2
updateState((draft) => set(draft, 'nested.field', value))
// Read nested state
const value = get(state, 'nested.path', defaultValue)
// ❌ WRONG - Multiple useState = multiple renders
const [password, setPassword] = useState('')
const [saveStatus, setSaveStatus] = useState('idle')
// Calling 2 setters = 2 render passes!
\```Why this works: Claude reads project-specific rules from .claude/rules/ and applies them when generating code. When I ask Claude to "create a settings form," it now automatically uses useImmer instead of multiple useState calls.
The combination is powerful:
- ESLint catches violations when code is written (by humans OR AI)
- Claude rules teach the AI to use the right pattern proactively
Make no mistake about it—this is how you scale good coding practices across your team AND your AI assistants.

9. Integration Workflow: Putting It All Together
Here's how to set this up in your project:
Step 1: Install dependencies
// package.json
{
"devDependencies": {
"@your-org/eslint-plugin": "^1.0.0",
"use-immer": "^0.9.0",
"lodash": "^4.17.21"
}
}Step 2: Configure ESLint
// .eslintrc.json
{
"plugins": ["@your-org/eslint-plugin"],
"rules": {
"@your-org/use-immer-state": ["warn", {
"minProperties": 3,
"minUseStateCount": 4
}]
}
}Step 3: Create Claude rule
# Create the rules directory
mkdir -p .claude/rules
# Add the code style rule
cat > .claude/rules/code-style.md << 'EOF'
## Use useImmer for Complex State
**RULE:** Single `useImmer({ state })` = 1 render. Multiple `useState` = N renders.
[Include the examples from section 8]
EOFStep 4: Run ESLint and see it in action
$ npm run lint
src/components/SettingsPanel.tsx
12:7 warning Multiple related useState calls detected. Consider consolidating into a single useImmer state object @your-org/use-immer-state
✖ 1 problem (0 errors, 1 warning)Step 5: Let Claude refactor it
Now when you ask Claude to "refactor SettingsPanel to follow our code standards," it reads the ESLint warnings AND the .claude/rules/code-style.md file, then automatically converts multiple useState to useImmer.
10. Real-World Before/After Comparison
Let me show you the difference this makes in a real production component.
Before (AI-generated with 7 useState):
- Component file size: 245 lines
- Number of
useStatecalls: 7 - Number of state setters called in
handleSave: 4 - Potential render cycles: 4+ (in async callback)
- Lines of state initialization: 14 lines
After (with useImmer):
- Component file size: 198 lines (19% reduction)
- Number of
useImmercalls: 1 - Number of
updateStatecalls inhandleSave: 3 (batched) - Guaranteed render cycles: 3 maximum
- Lines of state initialization: 9 lines (36% reduction)
Developer experience improvements:
- ✅ All related state co-located in one object
- ✅ No need to remember which setter goes with which state
- ✅ Refactoring state shape doesn't require updating 7 different variables
- ✅ ESLint catches the pattern automatically
- ✅ Claude generates the right pattern from the start
Performance wins:
- ✅ Fewer renders = smoother UI
- ✅ Less work for React's reconciliation algorithm
- ✅ Easier to optimize with React.memo (single state object dependency)
The numbers speak for themselves. But beyond the metrics, there's something powerful about teaching your tools—both ESLint and AI assistants—to guide you toward better patterns.
11. When NOT to Use This Pattern
I'd be doing you a disservice if I didn't mention when this pattern might NOT be the right choice:
1. Simple components with 1-2 primitive state values
// ❌ Overkill for simple state
const [isOpen, updateState] = useImmer({ isOpen: false })
// ✅ Just use useState
const [isOpen, setIsOpen] = useState(false)2. Form libraries with their own state management
If you're using React Hook Form, Formik, or similar libraries, they manage state internally. Don't fight their abstractions.
3. Third-party components expecting specific APIs
Some components expect [value, setValue] from useState. Don't break their contracts.
4. Performance-critical animations
For frame-by-frame animations, sometimes you want granular control over which state updates trigger renders. In these cases, multiple useState calls (or useReducer) might be more appropriate.
5. State that truly belongs in separate concerns
// These are genuinely separate concerns
const [userData, setUserData] = useState(null) // User profile
const [themePreference, setTheme] = useState('light') // UI preferenceDon't force-fit unrelated state into a single object just to satisfy the linter. Use your judgment.
Conclusion
And that concludes the end of this post! Let's recap what we covered:
The Problem: AI agents (and developers) often create React components with multiple useState calls, leading to render waterfalls and performance issues.
The Solution: Use useImmer to batch related state into a single object, guaranteeing single-render updates.
The Enforcement: Combine a custom ESLint rule that detects the anti-pattern with Claude-specific rules that teach the AI to use the right pattern proactively.
The Benefits:
- 🚀 Fewer renders = better performance
- 🧹 Cleaner code with consolidated state
- 🤖 Teachable AI that follows your standards
- ⚡ Automatic enforcement at lint time
The bigger picture? AI agents are powerful tools, but they need guard rails just like human developers. By combining automated linting with explicit instructions, we create systems that guide both humans and AI toward better patterns.
I hope you found this valuable and look out for more in the future!
Photo Credits:
- Photo by Google DeepMind on Pexels
- Photo by Antonio Batinić on Pexels
- Photo by Marc Mueller on Pexels
- Photo by Lukas on Pexels