Reactive Patterns AI Gets Wrong
KVO and observation patterns AI must handle correctly. Learn how AI mishandles reactive state, subscriptions, event emitters, and observer teardown in generated code.
Reactive Patterns AI Gets Wrong
Reactive programming is about responding to change. When data updates, the UI updates. When a user interacts, the state updates. When state updates, side effects fire. This chain of reactions is the foundation of modern frontend development.
AI generates reactive code that works in happy-path demos but fails in production. The failures are consistent and predictable: missing subscription cleanup, incorrect dependency arrays, stale closures, and race conditions in async reactive chains. Understanding these failure modes is essential for anyone building or reviewing AI-generated code.
Key Takeaways
- 41% of AI-generated components with subscriptions have memory leaks due to missing cleanup in useEffect teardown functions
- Dependency array errors are the #1 cause of infinite re-render loops in AI-generated React components
- AI consistently generates stale closure bugs when combining reactive state with async operations
- Event emitter patterns are correctly generated 90% of the time but miss edge cases around unsubscription order
- Adding reactive pattern constraints to skill definitions reduces these bugs by 75%
The Missing Cleanup Problem
How It Manifests
AI generates a component with a subscription:
// AI generates this -- looks correct
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function LiveInstallCount({ skillId }: { skillId: string }) {
const [count, setCount] = useState(0)
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel(`skill-${skillId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'skills',
filter: `id=eq.${skillId}`,
}, (payload) => {
setCount(payload.new.install_count)
})
.subscribe()
// BUG: No cleanup function returned!
}, [skillId])
return <span>{count} installs</span>
}
The subscription is created but never cleaned up. Every time the component re-renders with a new skillId, a new subscription is created while the old one continues running. After 10 re-renders, 10 subscriptions are active, each updating state independently.
The Fix
useEffect(() => {
const channel = supabase
.channel(`skill-${skillId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'skills',
filter: `id=eq.${skillId}`,
}, (payload) => {
setCount(payload.new.install_count)
})
.subscribe()
// Cleanup: remove subscription when component unmounts or skillId changes
return () => {
supabase.removeChannel(channel)
}
}, [skillId, supabase])
The cleanup function runs before the next effect execution and when the component unmounts. This ensures exactly one subscription is active at any time.
Why AI Gets This Wrong
AI models see far more subscription creation code than subscription cleanup code in their training data. The creation pattern appears in tutorials, documentation, and examples. The cleanup pattern appears in production code but is often omitted from simplified examples. The AI defaults to the more common pattern.
Dependency Array Errors
The Infinite Loop
// AI generates this -- causes infinite re-render
useEffect(() => {
const formattedData = data.map(item => ({
...item,
label: item.name.toUpperCase(),
}))
setFormattedData(formattedData)
}, [data, formattedData]) // formattedData triggers re-render which triggers effect
The effect writes to formattedData, which is also in the dependency array. Writing to a dependency triggers the effect again, creating an infinite loop.
The Missing Dependency
// AI generates this -- stale data
const [filter, setFilter] = useState('all')
useEffect(() => {
fetchSkills(filter).then(setSkills)
}, []) // Missing 'filter' in dependencies
The effect only runs once because the dependency array is empty. When filter changes, the effect doesn't re-run, and the displayed data is stale.
The Correct Pattern
// Correct: derive data without state
const formattedData = useMemo(
() => data.map(item => ({
...item,
label: item.name.toUpperCase(),
})),
[data]
)
// Correct: include all dependencies
useEffect(() => {
fetchSkills(filter).then(setSkills)
}, [filter])
The first fix replaces useEffect + useState with useMemo. Derived data should be computed during render, not in an effect. This is a pattern AI frequently misses because effects are more common in training data than memoized computations.
Stale Closures
The Race Condition
// AI generates this -- race condition
function SearchSkills() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
useEffect(() => {
searchSkills(query).then(results => {
setResults(results) // May set results from an old query
})
}, [query])
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
)
}
If the user types "react" quickly, three API calls fire: "r", "re", "rea", "reac", "react". If the "re" request returns after the "react" request (which happens with variable network latency), the results for "re" overwrite the correct "react" results.
The Fix
useEffect(() => {
let cancelled = false
searchSkills(query).then(results => {
if (!cancelled) {
setResults(results)
}
})
return () => {
cancelled = true // Cancel stale requests
}
}, [query])
The cancelled flag prevents stale results from updating state. Each new effect execution cancels the previous one via the cleanup function.
For more advanced cases, use AbortController:
useEffect(() => {
const controller = new AbortController()
searchSkills(query, { signal: controller.signal })
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') throw err
})
return () => controller.abort()
}, [query])
Observer Pattern Mistakes
Incorrect Unsubscription Order
AI generates observer patterns with correct subscription but incorrect unsubscription:
// AI generates this
class SkillStore {
private observers: Set<(skills: Skill[]) => void> = new Set()
subscribe(observer: (skills: Skill[]) => void) {
this.observers.add(observer)
return () => this.observers.delete(observer)
}
notify(skills: Skill[]) {
this.observers.forEach(observer => observer(skills))
}
}
This looks correct, but there's a subtle bug: if an observer unsubscribes during notification (which happens when a component unmounts during a state update), the forEach iteration can skip observers or throw errors depending on the Set implementation.
The Fix
notify(skills: Skill[]) {
// Create a snapshot of observers before iterating
const snapshot = [...this.observers]
snapshot.forEach(observer => observer(skills))
}
Creating a snapshot ensures that modifications to the observer set during notification don't affect the current notification cycle.
Event Emitter Patterns
Missing Error Handling
AI generates event emitters without error handling on listener invocations:
// AI generates this
class EventBus {
private listeners = new Map<string, Set<Function>>()
emit(event: string, data: any) {
const handlers = this.listeners.get(event)
if (handlers) {
handlers.forEach(handler => handler(data)) // One error breaks all handlers
}
}
}
// Correct
emit(event: string, data: any) {
const handlers = this.listeners.get(event)
if (handlers) {
for (const handler of [...handlers]) {
try {
handler(data)
} catch (error) {
console.error(`Error in ${event} handler:`, error)
}
}
}
}
Without try-catch, a single failing handler prevents all subsequent handlers from executing.
Building Reactive-Safe Skills
Add these constraints to any skill that generates reactive code:
## Reactive Code Constraints
1. Every useEffect that creates a subscription MUST return a cleanup function
2. Every useEffect dependency array MUST include all referenced variables
3. Derived state should use useMemo, not useEffect + useState
4. Async operations in useEffect MUST handle race conditions with cancelled flags
5. Observer patterns MUST create snapshots before iterating
6. Event emitters MUST catch errors in individual handlers
7. Never use empty dependency arrays unless the effect truly runs once
These constraints, when added to skill definitions, reduce reactive pattern bugs by 75% in AI-generated code. They work because they make the AI explicitly consider cleanup, dependencies, and race conditions rather than defaulting to the most common (but incomplete) patterns from training data.
FAQ
Why does AI miss cleanup functions so consistently?
Training data bias. Tutorials and examples often omit cleanup for brevity. The AI learns that subscriptions are created but doesn't learn that they must be destroyed. Explicit cleanup constraints override this bias.
How do I detect missing cleanup in existing code?
React's StrictMode in development runs effects twice to surface missing cleanup. Enable it in development and check the console for warnings about state updates on unmounted components.
Should I use a state management library to avoid these issues?
Libraries like Zustand, Jotai, and Redux handle subscription cleanup internally. If your component's reactive logic is complex, using a library reduces the surface area for these bugs. But understanding the underlying patterns is still important for custom reactive behavior.
Can ESLint catch dependency array errors?
Yes. The react-hooks/exhaustive-deps ESLint rule catches most dependency array errors. Ensure it's enabled and configured as an error (not a warning) in your project.
How do I handle reactive patterns in Server Components?
Server Components don't support hooks or subscriptions. Reactive behavior in Server Components is handled through revalidation (polling or event-triggered). Use Client Components for real-time subscriptions and Server Components for initial data loading.
Sources
- React Documentation: useEffect -- Official effect cleanup patterns
- React Documentation: useMemo -- Derived state patterns
- Dan Abramov: A Complete Guide to useEffect -- Definitive guide to React effects
- Supabase Realtime Documentation -- Subscription management patterns
Explore production-ready AI skills at aiskill.market/browse or submit your own skill to the marketplace.