React hook lifecycle rules for fullstack apps: cleanup patterns, race condition prevention, stale closure fixes, memory leak prevention, mutex/deduplication, and performance optimization. Auto-invoke when writing hooks, effects, callbacks, or real-time listeners.
Patterns distilled from 50+ memory leak, race condition, and performance issues across production React/React Native apps.
Rule: Every useEffect that creates a timer, listener, or subscription MUST return a cleanup function.
// WRONG -- setTimeout leaks if component unmounts
useEffect(() => {
const timer = setTimeout(doSomething, 5000)
}, [])
// RIGHT -- cleanup on unmount
useEffect(() => {
const timer = setTimeout(doSomething, 5000)
return () => clearTimeout(timer)
}, [])
// WRONG -- socket listeners accumulate on reconnect
useEffect(() => {
socket.on('data:updated', handleUpdate)
socket.on('item:created', handleCreate)
}, [socket])
// RIGHT -- remove old listeners before adding new ones
useEffect(() => {
socket.on('data:updated', handleUpdate)
socket.on('item:created', handleCreate)
return () => {
socket.off('data:updated', handleUpdate)
socket.off('item:created', handleCreate)
}
}, [socket])
// WRONG -- polling never stops
useEffect(() => {
const interval = setInterval(() => pollStatus(id), 3000)
}, [id])
// RIGHT -- clean up interval
useEffect(() => {
const interval = setInterval(() => pollStatus(id), 3000)
return () => clearInterval(interval)
}, [id])
Rule: When a useCallback reads state that may change between invocations, use a useRef to hold the current value.
// WRONG -- stale closure captures old submissionId, causes duplicate submissions
const handleSubmit = useCallback(async () => {
if (submissionId) return // stale! reads value from when callback was created
const id = await createSubmission()
setSubmissionId(id)
}, [submissionId])
// RIGHT -- ref is always current
const submissionIdRef = useRef<string | null>(null)
const handleSubmit = useCallback(async () => {
if (submissionIdRef.current) return // always reads latest value
const id = await createSubmission()
submissionIdRef.current = id
setSubmissionId(id)
}, [])
Rule: When useMemo captures Date.now(), the memoized value goes stale immediately. Either use a ref with a tick interval, or don't memoize time-dependent calculations.
// WRONG -- Date.now() captured once, never updates
const isUpcoming = useMemo(() => {
return event.startTime > Date.now()
}, [event.startTime])
// RIGHT -- recompute on every render (cheap comparison)
const isUpcoming = event.startTime > Date.now()
Rule: ALWAYS include all dependencies in useCallback/useMemo/useEffect dependency arrays. ESLint react-hooks/exhaustive-deps must pass.
Rule: ALWAYS wrap async operations that set loading/refreshing state in try/finally so the state resets on error.
// WRONG -- loading state stuck on true if any promise rejects
const onRefresh = async () => {
setRefreshing(true)
await Promise.all([refetchItems(), refetchOrders()])
setRefreshing(false) // never reached on error!
}
// RIGHT -- finally always runs
const onRefresh = async () => {
setRefreshing(true)
try {
await Promise.all([refetchItems(), refetchOrders()])
} finally {
setRefreshing(false)
}
}
Rule: When two async operations must both succeed for consistent state (e.g., signIn + submitProfile), handle partial failure with retry UI rather than silently continuing.
Rule: For operations that must not run concurrently (token refresh, form submission, payment creation), assign the promise synchronously before any await.
// WRONG -- race between check and assignment
let refreshPromise: Promise<Token> | null = null
async function refresh() {
if (refreshPromise) return refreshPromise
// Another call can reach here before the next line executes!
refreshPromise = await doRefresh() // BUG: await before assignment
return refreshPromise
}
// RIGHT -- assign promise synchronously
let refreshPromise: Promise<Token> | null = null
async function refresh() {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
try {
return await doRefresh()
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
Rule: For UI submission handlers, use a useRef<boolean> guard set synchronously:
const isSubmittingRef = useRef(false)
const handleSubmit = useCallback(async () => {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
try {
await submitForm()
} finally {
isSubmittingRef.current = false
}
}, [])
Rule: When useCallback has more than 5 dependencies, the function is doing too much. Extract into a React.memo child component that receives data via props.
// WRONG -- 10+ dependencies, breaks on any change
const handleAction = useCallback(() => {
// uses a, b, c, d, e, f, g, h, i, j
}, [a, b, c, d, e, f, g, h, i, j])
// RIGHT -- extract to child component
const ActionButton = React.memo(({ a, b, c, onAction }: Props) => {
const handlePress = useCallback(() => {
onAction(a, b, c)
}, [a, b, c, onAction])
return <Button onPress={handlePress} />
})
Rule: NEVER create components inline in render functions. They create new component instances every render, destroying state and causing remounts.
// WRONG -- new component instance every render
<FlatList
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
/>
// RIGHT -- module-level constant
const Separator = () => <View style={{ height: 8 }} />
<FlatList ItemSeparatorComponent={Separator} />
Rule: NEVER call the same query hook with identical variables in multiple sibling components. Standardize variables so the cache shares one entry, or lift the query to a common parent.
Rule: NEVER use React Native <Modal> for non-blocking overlays (toasts, banners, snackbars). <Modal> captures ALL touch events on native, blocking the entire underlying UI.
// WRONG -- toast blocks all touch events behind it
<Modal visible={showToast} transparent>
<Toast message="Item saved" />
</Modal>
// RIGHT -- absolute positioned view with pointer passthrough
{showToast && (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<Toast message="Item saved" />
</View>
)}
Use <Modal> only for dialogs that intentionally require user dismissal (confirmations, forms).
useEffect with setTimeout/setInterval/.on() and no cleanup returnuseCallback reading state that changes without using a refuseMemo capturing Date.now()setLoading(false) outside of finally blockpromise = await doWork() (should be promise = (async () => { ... })())useCallback with 6+ dependencies instead of extracting a child componentSeparatorComponent={() => <View ... />} -- inline component in render function<Modal> for toasts or banners