Refactoring AI-Generated React: Memory Leaks, Stale Closures, and the TypeScript Patterns That Prevent Them
AI-generated React code has a consistent failure mode: it looks reasonable, passes CI, and then quietly leaks memory in production. The patterns are not random. Every AI coding assistant makes the same category of mistakes, because those mistakes are invisible in unit tests and only surface under sustained real-world load. After auditing dozens of vibe-coded React frontends, we keep finding the same six culprits.
This post names them, shows how they appear in Chrome DevTools memory profiles, and gives the TypeScript lint configuration and component design patterns that prevent the AI from reintroducing them the next time you generate code.
Why AI React Code Leaks Memory
AI models are trained on code that was reviewed for correctness, not for long-running lifecycle behaviour. A useEffect that fetches data and sets state works perfectly in a test that mounts and immediately unmounts a component. It only leaks when a user leaves a tab open for thirty minutes, navigates away mid-fetch, and triggers a setState call on an unmounted component tree.
The AI cannot observe this failure. It generates code that matches the pattern it has seen most often — which is the happy path.
The result is frontends that are fine in staging, fine on demo day, and then produce creeping heap growth in production that the team notices three months later when their support queue fills with "the app feels sluggish" reports.
Pattern 1: Missing useEffect Cleanup Functions
This is the most common issue we find in AI-generated React code. The model generates a useEffect that starts a subscription, initiates a fetch, or sets up a timer — and omits the cleanup return.
What the AI writes:
useEffect(() => {
const subscription = someEventEmitter.on('update', handleUpdate);
}, [handleUpdate]);
What actually happens: Every remount (route change, React strict mode double-invoke, conditional rendering) adds a new subscriber. Old subscribers never unregister. In a dashboard with multiple such components, event listeners accumulate until the tab freezes.
The fix:
useEffect(() => {
const subscription = someEventEmitter.on('update', handleUpdate);
return () => subscription.off('update', handleUpdate);
}, [handleUpdate]);
Prevention — ESLint rule: eslint-plugin-react-hooks with exhaustive-deps catches most cases, but it does not enforce that a return value exists. Add react/useEffect-cleanup-require from eslint-plugin-react to your config and set it to error.
Pattern 2: Window Event Listeners Without Removal
AI assistants frequently attach listeners to window inside components. The model knows to use useEffect for side effects, but forgets that window outlives every component.
The leak:
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
When this component unmounts, handleResize stays registered. If handleResize closes over component state or props — which it almost always does — it holds a reference to the entire component closure, preventing garbage collection.
Fix and lint: The same cleanup pattern applies. For the lint side, no-restricted-syntax in ESLint can be configured to warn when window.addEventListener appears inside a function component without an accompanying removeEventListener in a return. This is blunt but effective as a catch-all.
Pattern 3: Stale Closures Over WebSocket and Async Handlers
Stale closures are the hardest pattern to spot in code review because the bug is about what the function captures, not what it does. AI models generate correct-looking code that happens to close over a stale ref.
Typical pattern from an AI assistant:
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const ws = new WebSocket(endpoint);
ws.onmessage = (event) => {
// messages here is the value from the first render
setMessages([...messages, JSON.parse(event.data)]);
};
return () => ws.close();
}, []); // empty deps — ws is created once, but messages is stale
Every message after the first appends to the original empty array. After a few minutes, only one message shows instead of the growing list.
The TypeScript-enforced fix uses the functional updater form:
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
This is the correct pattern for any state update inside a stable callback. We add a custom ESLint rule — or a comment in the system prompt if you are using Cursor — that enforces functional updater form for all setState calls inside useEffect and event handlers.
Pattern 4: React.memo Comparators That Compare References
AI models know about React.memo and often add it to list item components as a performance optimisation. What they consistently miss is that the default shallow equality check fails for object and array props — and that custom comparators written by the AI often compare the wrong thing.
AI-generated comparator:
export const ListItem = React.memo(({ item, config }: Props) => {
// ...
}, (prev, next) => prev.item === next.item && prev.config === next.config);
If config is an object literal created in the parent render (config={{ theme: 'dark' }}), this will always return false. The memo is useless, and worse, the comparator function runs on every parent render adding overhead.
The TypeScript pattern that prevents this: Enforce stable object identity at the call site using useMemo:
const config = useMemo(() => ({ theme: 'dark' }), []);
And document the contract with a type-level note. We add @stableRef JSDoc tags to props that must be referentially stable — these do not enforce anything, but they make the requirement visible to the AI when it reads the component as context.
Pattern 5: useCallback With Incomplete Dependency Arrays
AI assistants generate useCallback to memoize event handlers, but frequently omit dependencies that are used inside the callback. TypeScript does not catch this — the type system does not know which closure variables are relevant to useCallback's dependency array.
The generated code:
const handleSubmit = useCallback(async () => {
const result = await submitForm(formData); // formData not in deps
setResult(result);
}, [submitForm]); // formData missing
handleSubmit will always use the formData from the render in which useCallback memoized it — not the current one. Form submission sends stale data.
Lint fix: react-hooks/exhaustive-deps catches this, but only if you treat it as an error rather than a warn. We see the rule set to warn in almost every AI-generated ESLint config. Promote it to error. It generates some noise initially, but the bugs it prevents are worth the one-time cleanup cost.
Pattern 6: Callback Refs That Recreate on Every Render
The final pattern is subtler. AI models use inline functions as refs, which works — but creates a new function on every render, which triggers the ref callback twice (once with null, once with the element) on every update.
<div ref={(el) => { myRef.current = el; }} />
In a component that renders frequently, this creates unnecessary DOM access patterns. More dangerously, if the callback triggers any state update or effect, you get cascading re-renders.
The stable callback ref pattern:
const setRef = useCallback((el: HTMLDivElement | null) => {
myRef.current = el;
}, []); // stable — no deps, so never recreated
<div ref={setRef} />
TypeScript can enforce this through a custom utility type that marks ref callbacks as requiring useCallback wrapping, though enforcement at the type level requires a custom lint plugin. For most teams, the simpler rule is: never pass an inline function to ref. Put it in a code review checklist and in your Cursor/Copilot system prompt.
How to Find These Leaks in Chrome DevTools
When you suspect a React memory leak, the fastest diagnostic path is:
- Open DevTools → Memory tab
- Take a heap snapshot baseline
- Navigate through the suspected component several times (mount, interact, unmount)
- Take a second snapshot
- Filter the comparison by "Objects allocated between snapshots"
Look for Closure entries with large retained sizes. Clicking into them shows which function closed over what. A handleUpdate function with a retained size of several megabytes means an event listener is holding onto a large component tree.
For subscription-based leaks, the Listeners panel in DevTools shows every active event listener on window and DOM nodes — useful for confirming Pattern 2.
Configuring ESLint to Catch These at the Source
Here is the minimal ESLint configuration that prevents most of the above from being introduced by AI-generated code:
{
"plugins": ["react-hooks", "react"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react/jsx-no-bind": ["error", {
"allowArrowFunctions": false,
"allowFunctions": false,
"ignoreRefs": false
}],
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.name='window'][callee.property.name='addEventListener']",
"message": "window.addEventListener in a component requires a cleanup — ensure removeEventListener is called in the useEffect return."
}
]
}
}
The jsx-no-bind rule with ignoreRefs: false catches Pattern 6. The no-restricted-syntax selector for window.addEventListener flags Pattern 2. Combined with exhaustive-deps at error severity, this catches four of the six patterns at the linting stage before any code reaches review.
The Bigger Picture: AI Code Needs a Different Kind of Review
AI coding assistants are fast at producing code that compiles. They are poor at reasoning about lifecycle, reference stability, and the gap between "runs correctly once" and "runs correctly for thirty minutes under real load."
The practical implication is that AI-generated React code needs reviewers who specifically look for lifecycle correctness — not just logic correctness. This is a different skill, and most teams' review checklists were written for human-generated code that rarely got these things wrong by accident.
If you are working with a vibe-coded React frontend and noticing sluggish performance after extended use, these six patterns are the right place to start. We audit and remediate React frontends regularly through our code quality consulting and custom software development services.
If you would rather talk through your specific situation first, reach out at hello@wolf-tech.io or visit wolf-tech.io. A thirty-minute call is usually enough to identify whether the leak is something your team can fix with a lint rule or whether the component design needs a more substantial refactor.

