React 18's concurrent features have reshaped our understanding of the "rendering process." Traditional Hook usage patterns may expose new issues in concurrent mode. Mastering these best practices is required to truly enjoy React 18's performance dividends.
useEffect Is Synchronous, but the Outside World Isn't
In concurrent mode, an Effect's async callback may arrive long after its cleanup function has already run. The code example uses the standard pattern of AbortController + mountedRef:
import { useState, useEffect, useTransition, useDeferredValue, useMemo, useCallback, useRef } from 'react'
interface User {
id: number
name: string
email: string
bio: string
}
function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
function UserSearch() {
const [query, setQuery] = useState('')
const [users, setUsers] = useState<User[]>([])
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const mountedRef = useRef(true)
const debouncedQuery = useDebounce(query, 250)
const deferredQuery = useDeferredValue(debouncedQuery)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
abortRef.current?.abort()
}
}, [])
useEffect(() => {
if (!deferredQuery.trim()) {
setUsers([])
return
}
const controller = new AbortController()
abortRef.current?.abort()
abortRef.current = controller
;(async () => {
try {
setError(null)
const res = await fetch(`/api/users?q=${encodeURIComponent(deferredQuery)}`, {
signal: controller.signal
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (mountedRef.current) {
startTransition(() => setUsers(data))
}
} catch (e: any) {
if (e.name !== 'AbortError' && mountedRef.current) {
setError(e.message)
}
}
})()
return () => controller.abort()
}, [deferredQuery])
const highlightText = useCallback((text: string, keyword: string) => {
if (!keyword) return text
const parts = text.split(new RegExp(`(${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'))
return parts.map((p, i) =>
p.toLowerCase() === keyword.toLowerCase() ? <mark key={i}>{p}</mark> : <span key={i}>{p}</span>
)
}, [])
const filteredList = useMemo(() => users.slice(0, 50), [users])
return (
<div className="search-box">
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索用户..."
className="input"
/>
{error && <div className="error">{error}</div>}
<div className={isPending ? 'results pending' : 'results'}>
{filteredList.map(user => (
<div key={user.id} className="user-item">
<strong>{highlightText(user.name, deferredQuery)}</strong>
<span className="email">{user.email}</span>
</div>
))}
{users.length === 0 && deferredQuery && !isPending && !error && (
<div className="empty">无匹配结果</div>
)}
</div>
</div>
)
}
useMemo and useCallback Optimization Boundaries
Overusing useMemo/useCallback is a hallmark of beginners. Use this decision table to know when to apply them:
| Scenario | Should Use? | Reason |
|---|---|---|
| Child wrapped in React.memo receiving reference-type props | ✅ Yes | Avoids unnecessary re-renders |
| Used as useEffect dependency and is array/object | ✅ Yes | Prevents accidental Effect triggers |
| Expensive computation (sort/iterate >1000 items/heavy regex) | ✅ Yes | CPU savings outweigh memo overhead |
Simple calc like a + b or object with <3 fields |
❌ No | Memo comparison itself is costlier |
| Functions passed to native DOM elements | ❌ No | React doesn't memo DOM callbacks |
| Functions passed to custom Hooks that don't depend on them | ❌ No | No tangible benefit |
Three Weapons of Concurrent Mode
React 18 provides three keys to unlock concurrent UX:
// 1. startTransition: Mark non-urgent updates
startTransition(() => setFilteredItems(heavyCompute()))
// 2. useDeferredValue: Defer downstream computation of a value
const deferredList = useDeferredValue(hugeList)
// 3. Suspense + use: Await async resources (React 18.2+)
const user = use(fetchUserPromise)
Key difference: useTransition proactively wraps your update action; useDeferredValue passively delays propagation of a specific prop/state.
Three Techniques to Avoid Stale Closures
- Ref for latest value: Sync values with
useRef+useEffectfor async callbacks to read - Functional updates: Pass
(prev) => nextto setState instead of depending on external values - Treat effects as subscriptions: Fill the dependency array strictly; use eslint-plugin-react-hooks if you don't trust yourself
Best Practices
- Always write Effect dependencies honestly — if you want "run once" semantics, first question your design
- One Hook does one thing — split custom Hooks over 100 lines
- Use mature data-fetching libs (SWR / React Query / TanStack Query) instead of hand-writing useEffect
- Lift state to the right level — solve with Lifting State before reaching for Context/Redux
- Split Context into fine-grained pieces — a single global UserContext causes mindless re-renders of all consumers
Summary
React 18 is not an incremental upgrade "adding a few Hooks" — it's a ground-up rewrite of the scheduling model. The key to embracing concurrent mode is abandoning the old mindset of "Effect = componentDidMount" and adopting the new paradigm of "declaring synchronization and cleanup relationships of side effects." The purer and more declarative your code, the more React 18 can optimize performance for you.