# useEffect Ban — Complete Agent Guide
## Purpose
This document is the authoritative reference for AI agents and developers
operating under a **no-direct-useEffect** policy. Every `useEffect` call in
application code must be replaced with one of five declarative patterns.
The only permitted wrapper is `useMountEffect`, defined once in the codebase.
---
## The useMountEffect hook
```typescript
export function useMountEffect(effect: () => void | (() => void)) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, []);
}
```
This is the **only** place `useEffect` is called directly. All other usage goes
through this hook or is eliminated entirely.
---
## Why useEffect is banned
### Production failure modes
1. **Brittleness** — Dependency arrays hide coupling. A seemingly unrelated
refactor can silently change effect behavior.
2. **Infinite loops** — `state update -> render -> effect -> state update` loops
are easy to create, especially when dependency lists are "fixed"
incrementally.
3. **Dependency hell** — Effect chains (A sets state that triggers B) are
time-based control flow. They are hard to trace and easy to regress.
4. **Debugging pain** — "Why did this run?" or "Why did this not run?" has no
clear entrypoint like an event handler does.
5. **Agent amplification** — AI agents add `useEffect` "just in case," seeding
the next race condition or infinite loop.
### Failure mode comparison
| Pattern | Failure mode |
|---------|-------------|
| `useMountEffect` | Binary and loud — it ran once, or not at all |
| Direct `useEffect` | Gradual degradation — flaky behavior, perf issues, loops before hard failure |
---
## The five replacement patterns
### Pattern 1: Derive state inline
**When:** You are computing a value from existing state or props.
**Principle:** If value B is a pure function of value A, compute B inline.
Do not store it in state and sync it with an effect.
```tsx
// ---- BAD ----
function ProductList() {
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
setFilteredProducts(products.filter((p) => p.inStock));
}, [products]);
return ;
}
// ---- GOOD ----
function ProductList() {
const [products, setProducts] = useState([]);
const filteredProducts = products.filter((p) => p.inStock);
return ;
}
```
**Loop hazard example:**
```tsx
// ---- BAD: total in deps can loop ----
function Cart({ subtotal }) {
const [tax, setTax] = useState(0);
const [total, setTotal] = useState(0);
useEffect(() => {
setTax(subtotal * 0.1);
}, [subtotal]);
useEffect(() => {
setTotal(subtotal + tax);
}, [subtotal, tax, total]); // total in deps = infinite loop
return {total};
}
// ---- GOOD ----
function Cart({ subtotal }) {
const tax = subtotal * 0.1;
const total = subtotal + tax;
return {total};
}
```
**Expensive computations:** Use `useMemo` for costly derivations, not
`useEffect` + `setState`.
```tsx
// GOOD: Memoized derivation
const filtered = useMemo(
() => products.filter((p) => p.inStock),
[products]
);
```
**Smell test:**
- You are about to write `useEffect(() => setX(deriveFromY(y)), [y])`
- You have state that only mirrors other state or props
- You have chained effects where one sets state consumed by another
---
### Pattern 2: Use data-fetching libraries
**When:** You need to fetch data from an API based on props or state.
**Principle:** Data fetching has solved problems (caching, cancellation, race
conditions, retries, stale-while-revalidate) that you should not re-implement
in an effect.
```tsx
// ---- BAD: Race condition risk ----
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchProduct(productId).then((data) => {
setProduct(data); // May set stale data if productId changed
setLoading(false);
});
}, [productId]);
}
// ---- GOOD: TanStack Query ----
function ProductPage({ productId }) {
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
}
// ---- GOOD: SWR ----
function ProductPage({ productId }) {
const { data: product, isLoading } = useSWR(
`/api/products/${productId}`,
fetcher
);
}
// ---- GOOD: Next.js Server Component ----
async function ProductPage({ params }) {
const product = await fetchProduct(params.productId);
return ;
}
```
**Smell test:**
- Your effect does `fetch(...)` then `setState(...)`
- You are re-implementing caching, retries, cancellation, or stale handling
- You have loading/error state managed alongside the fetch effect
---
### Pattern 3: Event handlers, not effects
**When:** A user action or discrete event should trigger work.
**Principle:** If there is a clear cause (click, submit, keypress, message),
do the work at the point of the cause. Do not relay it through state + effect.
```tsx
// ---- BAD: Effect as action relay ----
function LikeButton() {
const [liked, setLiked] = useState(false);
useEffect(() => {
if (liked) {
postLike();
setLiked(false);
}
}, [liked]);
return ;
}
// ---- GOOD: Direct event-driven action ----
function LikeButton() {
return ;
}
```
**Form submission example:**
```tsx
// ---- BAD ----
function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const [formData, setFormData] = useState({});
useEffect(() => {
if (submitted) {
sendForm(formData);
setSubmitted(false);
}
}, [submitted, formData]);
return (
);
}
// ---- GOOD ----
function ContactForm() {
const [formData, setFormData] = useState({});
function handleSubmit(e) {
e.preventDefault();
sendForm(formData);
}
return ;
}
```
**Smell test:**
- State is used as a flag so an effect can do the real action
- You are building "set flag -> effect runs -> reset flag" mechanics
- The effect is responding to a discrete event, not continuous synchronization
---
### Pattern 4: useMountEffect for one-time external sync
**When:** You need to synchronize with an external system exactly once on
mount and clean up on unmount.
**Principle:** Some side effects are inherently imperative (DOM manipulation,
third-party libraries, browser APIs). Wrap them in `useMountEffect` to make
intent explicit and keep the codebase searchable.
```tsx
// ---- GOOD: DOM integration ----
function AutoFocusInput() {
const ref = useRef(null);
useMountEffect(() => {
ref.current?.focus();
});
return ;
}
// ---- GOOD: Third-party widget ----
function MapWidget({ center }) {
const containerRef = useRef(null);
useMountEffect(() => {
const map = new MapLibrary(containerRef.current!, { center });
return () => map.destroy();
});
return ;
}
// ---- GOOD: Browser API subscription ----
function WindowSize() {
const [size, setSize] = useState({ w: innerWidth, h: innerHeight });
useMountEffect(() => {
const handler = () => setSize({ w: innerWidth, h: innerHeight });
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
});
return {size.w} x {size.h};
}
```
**Conditional mounting — prefer tree structure over guards:**
```tsx
// ---- BAD: Guard inside effect ----
function VideoPlayer({ isLoading }) {
useEffect(() => {
if (!isLoading) playVideo();
}, [isLoading]);
}
// ---- GOOD: Mount only when preconditions are met ----
function VideoPlayerWrapper({ isLoading }) {
if (isLoading) return ;
return ;
}
function VideoPlayer() {
useMountEffect(() => playVideo());
}
// ---- ALSO GOOD: Persistent shell + conditional instance ----
function VideoPlayerContainer({ isLoading }) {
return (
<>
{!isLoading && }
>
);
}
function VideoPlayerInstance() {
useMountEffect(() => playVideo());
}
```
**Smell test:**
- You are synchronizing with an external system
- The behavior is naturally "setup on mount, cleanup on unmount"
- You have an effect with an empty dependency array
---
### Pattern 5: Reset with `key`, not dependency choreography
**When:** A component should start completely fresh when an identifier changes.
**Principle:** React already has a mechanism for "destroy and recreate" — the
`key` prop. Use it instead of writing an effect that manually resets state.
```tsx
// ---- BAD ----
function VideoPlayer({ videoId }) {
const [progress, setProgress] = useState(0);
useEffect(() => {
setProgress(0);
loadVideo(videoId);
}, [videoId]);
}
// ---- GOOD ----
function VideoPlayer({ videoId }) {
const [progress, setProgress] = useState(0);
useMountEffect(() => {
loadVideo(videoId);
});
}
// Parent:
function VideoPage({ videoId }) {
return ;
}
```
**Chat room example:**
```tsx
// ---- BAD ----
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
setMessages([]);
const unsub = subscribeToRoom(roomId, setMessages);
return unsub;
}, [roomId]);
}
// ---- GOOD ----
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useMountEffect(() => {
return subscribeToRoom(roomId, setMessages);
});
}
// Parent:
function ChatPage({ roomId }) {
return ;
}
```
**Smell test:**
- Your effect's only job is to reset local state when an ID/prop changes
- You want the component to behave like a brand-new instance for each entity
- You are writing cleanup logic that mirrors setup logic inside the same effect
---
## Decision flowchart
When you encounter or are about to write a `useEffect`, follow this decision
tree:
1. **Is the value derived from state/props?** → Compute inline (Pattern 1)
2. **Is it fetching data?** → Use a data-fetching library (Pattern 2)
3. **Is it responding to a user action?** → Move to event handler (Pattern 3)
4. **Is it one-time setup/teardown on mount?** → `useMountEffect` (Pattern 4)
5. **Does the component need to reset when an ID changes?** → Use `key` (Pattern 5)
6. **None of the above?** → Reconsider whether the effect is necessary at all.
If it truly is, use `useMountEffect` with a comment explaining why.
---
## Migration guide
### Step 1: Audit
Find all `useEffect` calls:
```bash
grep -rn "useEffect" --include="*.tsx" --include="*.ts" --include="*.jsx" --include="*.js" src/
```
### Step 2: Categorize
For each call, determine which pattern applies using the decision flowchart.
### Step 3: Add the useMountEffect hook
Create `src/hooks/useMountEffect.ts`:
```typescript
import { useEffect } from 'react';
/**
* Runs an effect exactly once on mount with optional cleanup on unmount.
* This is the only sanctioned way to call useEffect in this codebase.
*/
export function useMountEffect(effect: () => void | (() => void)) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, []);
}
```
### Step 4: Add ESLint enforcement
```json
{
"rules": {
"no-restricted-imports": ["error", {
"paths": [{
"name": "react",
"importNames": ["useEffect"],
"message": "Direct useEffect is banned. Use useMountEffect from '@/hooks/useMountEffect' or a declarative pattern. See AGENTS.md for alternatives."
}]
}]
}
}
```
### Step 5: Refactor incrementally
Replace each `useEffect` with the appropriate pattern. Prioritize:
1. Effects that have caused production bugs
2. Effects with complex dependency arrays
3. Effects that chain with other effects
4. Simple derivations (quick wins)
---
## Edge cases and exceptions
### Custom hooks that wrap useEffect internally
Library-level hooks (e.g., `useEventListener`, `useInterval`,
`useMediaQuery`) may use `useEffect` internally. This is acceptable because:
- The effect logic is encapsulated behind a clear interface
- The dependency management is tested and centralized
- Application code never sees `useEffect` directly
### Server components
Server components cannot use hooks at all. This rule applies only to client
components (`"use client"`).
### Refs that need post-render measurement
```tsx
// This is a valid useMountEffect use case
function Tooltip({ children }) {
const ref = useRef(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
useMountEffect(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}
});
return
{children}
;
}
```
### Animation frame loops
```tsx
function AnimatedValue({ target }) {
const [value, setValue] = useState(target);
useMountEffect(() => {
let frame: number;
function animate() {
setValue((v) => v + (target - v) * 0.1);
frame = requestAnimationFrame(animate);
}
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
});
}
// Note: For animations that depend on changing props, use key-based
// remounting (Pattern 5) or a dedicated animation library.
```
---
## Architectural benefit
Banning `useEffect` is a forcing function for better component tree design:
- **Parents** own orchestration and lifecycle boundaries
- **Children** can assume preconditions are already met
- **Each component** does one job (Unix philosophy)
- **Coordination** happens at clear boundaries, not inside hidden effect chains
This produces simpler components, fewer hidden side effects, and a codebase
that is easier to reason about for both humans and AI agents.