3: React Intermediate
These four patterns appear constantly in real React apps. Each section shows the naive version, the bugs it contains, and the corrected version — the same progression you’d go through debugging a production issue.
1. useEffect and Data Fetching
useEffect runs side effects after React commits a render to the DOM. “Side effect” means anything that reaches outside the component’s pure render function: network requests, timers, subscriptions, direct DOM access.
The signature:
useEffect(() => {
// runs after render
return () => {
// cleanup: runs before next effect execution, or on unmount
}
}, [dependencies])Why the dependency array matters — at the closure level. React re-runs the effect whenever a value in the dependency array changes between renders. The effect function is a closure over the render’s local variables. If you omit a value that the effect reads, the effect runs with a stale closure — it sees the values from the render when it was created, not the current values. ESLint’s exhaustive-deps rule catches this automatically.
// Bug: stale closure — effect reads 'userId' from the first render's closure
// and never re-runs when userId changes, so the wrong user's data is shown
useEffect(() => {
fetchUser(userId).then(setUser)
}, []) // missing userId in deps
// Correct: effect re-runs when userId changes, fetches fresh data
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId])The race condition. This is the most common useEffect data-fetching bug. If userId changes quickly (user clicks through a list), two fetches are in-flight simultaneously. The second might resolve before the first. The first’s response then overwrites the correct data with stale data — and no error is thrown.
The unmount update warning. If a component unmounts while a fetch is in-flight, the .then() callback still runs after unmount and calls setState on an unmounted component. React warns about this in development.
Both problems have the same fix: AbortController.
import { useState, useEffect } from 'react'
type FetchState<T> =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: T }
interface Post {
id: number
title: string
body: string
}
function PostList() {
const [state, setState] = useState<FetchState<Post[]>>({ status: 'loading' })
useEffect(() => {
// Create a controller for this effect execution
const controller = new AbortController()
setState({ status: 'loading' })
fetch('https://jsonplaceholder.typicode.com/posts', {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<Post[]>
})
.then(data => setState({ status: 'success', data }))
.catch(err => {
// AbortError fires when the controller aborts — not a real error
if (err.name === 'AbortError') return
setState({ status: 'error', message: err.message })
})
// Cleanup: abort the in-flight request when the effect re-runs or component unmounts
return () => controller.abort()
}, []) // empty array: run once on mount
if (state.status === 'loading') return <p>Loading...</p>
if (state.status === 'error') return <p style={{ color: 'red' }}>{state.message}</p>
return (
<div>
<h2>Posts</h2>
<ul>
{state.data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}The FetchState discriminated union replaces three separate isLoading / error / data variables. The union makes impossible states unrepresentable: you can’t have status: 'success' without data, and TypeScript enforces that you handle all three branches. Inside status === 'success', TypeScript knows state.data exists — no null checks needed.
The async/await equivalent. You can’t make the useEffect callback itself async (it would return a Promise, not a cleanup function). The standard workaround is an inner async function:
useEffect(() => {
const controller = new AbortController()
async function load() {
try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: Post[] = await res.json()
setState({ status: 'success', data })
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setState({ status: 'error', message: err instanceof Error ? err.message : 'Unknown error' })
}
}
setState({ status: 'loading' })
load()
return () => controller.abort()
}, [url])Both patterns are correct. The .then() chain is slightly less code; async/await reads more like sequential imperative code. Pick one and be consistent within a codebase.
2. Search / Filter Pattern
The architectural decision here is whether to store filtered results in state or derive them during render. Never store derived state. Derived state creates synchronization problems: the source data and the filtered copy can get out of sync, and you now have two sources of truth for the same logical value.
// Wrong — filtered is derived from items and query, don't store it separately
const [items, setItems] = useState<Item[]>([])
const [query, setQuery] = useState('')
const [filtered, setFiltered] = useState<Item[]>([]) // redundant, will go stale
// Correct — derive filtered during render
const [items, setItems] = useState<Item[]>([])
const [query, setQuery] = useState('')
const filtered = items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
)When to memoize with useMemo. For small arrays (< few hundred items), the filter runs fast enough that memoization adds more complexity than it saves. For large datasets or expensive predicates, useMemo avoids recomputing on every render:
import { useState, useMemo } from 'react'
interface Item {
id: number
name: string
category: 'Frontend' | 'Backend' | 'Language' | 'Database'
}
const items: Item[] = [
{ id: 1, name: 'React', category: 'Frontend' },
{ id: 2, name: 'Node.js', category: 'Backend' },
{ id: 3, name: 'TypeScript', category: 'Language' },
{ id: 4, name: 'PostgreSQL', category: 'Database' },
{ id: 5, name: 'Next.js', category: 'Frontend' },
{ id: 6, name: 'Express', category: 'Backend' },
{ id: 7, name: 'Python', category: 'Language' },
{ id: 8, name: 'MongoDB', category: 'Database' },
]
function SearchFilter() {
const [query, setQuery] = useState('')
// useMemo: recomputes only when items or query changes
// For this list it's overkill; for 10k items it matters
const filtered = useMemo(
() => items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
),
[query] // items is module-level constant, so no dep needed
)
return (
<div>
<label htmlFor="search">Search technologies</label>
<input
id="search"
placeholder="Search..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
<ul role="list" aria-label="Filtered results" aria-live="polite">
{filtered.map(item => (
<li key={item.id}>
{item.name}
<span aria-label={`Category: ${item.category}`} style={{ marginLeft: 8, opacity: 0.6 }}>
{item.category}
</span>
</li>
))}
{filtered.length === 0 && (
<li>No results for "{query}"</li>
)}
</ul>
</div>
)
}aria-live="polite" on the results list announces count changes to screen readers when the filter updates — “8 results” becomes “2 results” after typing, and assistive technology reads the updated list without the user having to navigate to it.
Debouncing for API-backed search. When search triggers a network request (not a local filter), you don’t want to fire on every keystroke. The standard pattern is a debounced effect:
useEffect(() => {
if (!query) return
const timer = setTimeout(() => {
fetchResults(query).then(setResults)
}, 300) // wait 300ms after last keystroke
return () => clearTimeout(timer)
}, [query])The cleanup function cancels the pending timeout when query changes again — so only the final keystroke after a 300ms pause actually fires the request.
3. Forms with Multiple Inputs
The handleChange type problem. The naive version types the event as any or leaves it untyped:
// Untyped — TypeScript can't catch errors in here
const handleChange = (e) => setData({ ...data, [e.target.name]: e.target.value })The correct event type depends on which element fired it. A union handles both <input> and <textarea>:
type FormEvent = React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
const handleChange = (e: FormEvent) => {
setData(prev => ({ ...prev, [e.target.name]: e.target.value }))
}The dynamic key type problem. [e.target.name] is a string, but your form state has specific keys. TypeScript can’t verify the key is valid without an explicit assertion:
interface FormData {
name: string
email: string
message: string
}
// TypeScript error: string is not assignable to keyof FormData
setData(prev => ({ ...prev, [e.target.name]: e.target.value }))
// Correct: assert the key type, and validate that the field exists
const key = e.target.name as keyof FormData
if (!(key in data)) return // guard against unexpected field names
setData(prev => ({ ...prev, [key]: e.target.value }))Label association. Every <label> must be associated with its input via htmlFor matching the input’s id. Without this, clicking the label text doesn’t focus the input — a basic usability requirement that screen readers also depend on.
import { useState } from 'react'
interface FormData {
name: string
email: string
message: string
}
const INITIAL_FORM: FormData = { name: '', email: '', message: '' }
function ContactForm() {
const [data, setData] = useState<FormData>(INITIAL_FORM)
const [submitted, setSubmitted] = useState(false)
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const key = e.target.name as keyof FormData
setData(prev => ({ ...prev, [key]: e.target.value }))
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log('Submitted:', data)
setSubmitted(true)
setData(INITIAL_FORM)
}
if (submitted) return <p role="status">Message sent!</p>
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={data.name}
onChange={handleChange}
required
autoComplete="name"
/>
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={data.email}
onChange={handleChange}
required
autoComplete="email"
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={data.message}
onChange={handleChange}
required
rows={4}
/>
</div>
<button type="submit">Send</button>
</form>
)
}noValidate on the form disables native browser validation UI while letting you use the required attribute — useful when you want to implement custom validation feedback instead of the browser’s default popups. autoComplete on name and email fields lets the browser fill in saved data, which reduces friction for real users.
When to reach for react-hook-form. For forms with validation, error messages, touched/dirty state, and async submission, the manual useState approach accumulates significant boilerplate. react-hook-form is the standard library for this: it uses uncontrolled inputs internally (better performance at scale) and integrates with Zod for schema-based validation.
4. Loading and Error States
The isLoading / error / data pattern has a logical flaw: it allows impossible states. isLoading = true and error = "failed" simultaneously is incoherent, but the type system allows it. You end up writing defensive code for states that shouldn’t exist.
// Naive — three variables, many impossible combinations
const [data, setData] = useState<User[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// isLoading && error? isLoading && data? Both are typeable but meaningless.A discriminated union collapses this to exactly the states that can actually occur:
import { useState, useEffect } from 'react'
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: T }
interface User {
id: number
name: string
email: string
}
function UserDirectory() {
const [state, setState] = useState<AsyncState<User[]>>({ status: 'loading' })
useEffect(() => {
const controller = new AbortController()
fetch('https://jsonplaceholder.typicode.com/users', { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json() as Promise<User[]>
})
.then(data => setState({ status: 'success', data }))
.catch(err => {
if (err.name === 'AbortError') return
setState({ status: 'error', message: err.message })
})
return () => controller.abort()
}, [])
// TypeScript exhaustive check: all four branches handled
switch (state.status) {
case 'idle':
return null
case 'loading':
return (
<div role="status" aria-live="polite">
<p>Loading users...</p>
</div>
)
case 'error':
return (
<div role="alert">
<p>Failed to load users: {state.message}</p>
<button onClick={() => setState({ status: 'loading' })}>Retry</button>
</div>
)
case 'success':
return (
<div>
<h2>User Directory</h2>
<ul>
{state.data.map(user => (
<li key={user.id}>
<strong>{user.name}</strong>
<span style={{ marginLeft: 8, opacity: 0.6 }}>{user.email}</span>
</li>
))}
</ul>
</div>
)
}
}Inside case 'success', TypeScript narrows state to { status: 'success'; data: User[] } — state.data is known to exist, no null check needed. Inside case 'error', state.message exists. If you add a new variant to AsyncState and forget to add a case to the switch, TypeScript will warn if you have a default that uses never (or if the function has a non-void return type that requires all paths to return).
role="status" vs role="alert". The loading state uses role="status" (polite announcement — screen reader waits for idle) and the error state uses role="alert" (assertive — interrupts immediately). This is the correct semantic distinction: a loading spinner is informational, an error demands attention.
Abstracting into a custom hook. Once this pattern appears in more than one component, it belongs in a hook:
function useFetch<T>(url: string): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({ status: 'loading' })
useEffect(() => {
const controller = new AbortController()
setState({ status: 'loading' })
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
})
.then(data => setState({ status: 'success', data }))
.catch(err => {
if (err.name === 'AbortError') return
setState({ status: 'error', message: err.message })
})
return () => controller.abort()
}, [url])
return state
}
// Usage — the component no longer owns any async logic
function UserDirectory() {
const state = useFetch<User[]>('https://jsonplaceholder.typicode.com/users')
// ... same switch statement
}Custom hooks are the React primitive for sharing stateful logic. The rule: if a useEffect + useState pair appears in more than one component, extract it. The hook owns the complexity; components stay declarative.