5: Intermediate Practice Components
Three components built from the intermediate patterns. Each one is annotated around the production issues that emerge when the tutorial patterns hit real constraints: API rate limits, filter state persistence, and form submit race conditions.
1. GithubRepos
Concepts used: useEffect, fetch with AbortController, discriminated union async state, semantic table HTML, component extraction
Fetches real GitHub repo data and displays it in a table. The interesting engineering is in the state shape and cleanup.
The naive state shape and its problems:
// Three variables — allows impossible states
const [repos, setRepos] = useState([]) // untyped: TypeScript can't check usage
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) // string | null, but TypeScript sees nullrepos typed as never[] (the inferred type of []) means TypeScript can’t catch incorrect field access. loading && error is representable but meaningless. The corrected version uses a discriminated union and AbortController:
import { useState, useEffect } from 'react'
interface Repo {
id: number
name: string
description: string | null
stargazers_count: number
html_url: string
}
type AsyncState<T> =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: T }
interface RepoTableProps {
repos: Repo[]
user: string
}
function RepoTable({ repos, user }: RepoTableProps) {
return (
<table>
<caption>
GitHub repos for <a
href={`https://github.com/${user}`}
target="_blank"
rel="noopener noreferrer"
>
{user}
</a> ({repos.length} shown)
</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Description</th>
<th scope="col">Stars</th>
</tr>
</thead>
<tbody>
{repos.map(repo => (
<tr key={repo.id}>
<td>
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${repo.name} on GitHub`}
>
{repo.name}
</a>
</td>
<td>{repo.description ?? <em style={{ opacity: 0.5 }}>No description</em>}</td>
<td>{repo.stargazers_count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)
}
export default function GithubRepos() {
const [state, setState] = useState<AsyncState<Repo[]>>({ status: 'loading' })
useEffect(() => {
const controller = new AbortController()
fetch('https://api.github.com/users/lxyhan/repos?sort=stars&per_page=10', {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${res.statusText}`)
return res.json() as Promise<Repo[]>
})
.then(data => setState({ status: 'success', data }))
.catch(err => {
if (err.name === 'AbortError') return
setState({ status: 'error', message: err.message })
})
return () => controller.abort()
}, [])
if (state.status === 'loading') {
return <p role="status">Loading repos...</p>
}
if (state.status === 'error') {
return <p role="alert" style={{ color: 'red' }}>{state.message}</p>
}
return <RepoTable repos={state.data} user="lxyhan" />
}Three decisions worth explaining:
<th scope="col"> in the header row instead of <td>. <th> with a scope attribute tells screen readers that these cells are column headers — they’re announced before each data cell as the user navigates the table. Using <td> in <thead> is valid HTML but loses the semantic relationship.
rel="noopener noreferrer" on external links. target="_blank" without rel="noopener" gives the opened page access to window.opener — a security issue. Modern browsers mitigate this automatically, but the attribute signals intent and ensures consistent behavior across older browsers.
GitHub API rate limiting. The unauthenticated GitHub API allows 60 requests per hour per IP. In a shared environment (office NAT, CI), multiple people hitting this component will exhaust the quota quickly, and the error state will show. The correct production solution is to either add an auth token via a server-side proxy (never expose tokens to the client) or cache the response in a server-side layer and serve it statically.
Live demo:
loading
2. TechSearch
Concepts used: multi-dimensional filter, derived state, typed union for filter values, URL state tradeoff, controlled <select>
Two independent filters — text and category — applied via chained .filter(). The state shape and persistence model are where the interesting decisions live.
Typing the category filter. The naive version stores category as string, which means TypeScript can’t catch invalid category values or missing cases:
// Untyped — 'Frontned' typo is valid TypeScript
const [category, setCategory] = useState('All')
// Typed — invalid values fail at compile time
type Category = 'All' | 'Frontend' | 'Backend' | 'Database' | 'Language'
const [category, setCategory] = useState<Category>('All')Deriving categories from a typed constant rather than maintaining a parallel array ensures the dropdown options and the filter logic stay in sync:
import { useState, useMemo } from 'react'
type Category = 'All' | 'Frontend' | 'Backend' | 'Database' | 'Language'
interface Tech {
id: number
name: string
category: Exclude<Category, 'All'> // items don't have category 'All'
description: string
}
const CATEGORIES: Category[] = ['All', 'Frontend', 'Backend', 'Database', 'Language']
const techStack: Tech[] = [
{ id: 1, name: 'React', category: 'Frontend', description: 'UI library for building component-based interfaces' },
{ id: 2, name: 'Vue', category: 'Frontend', description: 'Progressive framework for building UIs' },
{ id: 3, name: 'Node.js', category: 'Backend', description: 'JavaScript runtime built on V8 engine' },
{ id: 4, name: 'PostgreSQL', category: 'Database', description: 'Advanced open-source relational database' },
{ id: 5, name: 'TypeScript', category: 'Language', description: 'Typed superset of JavaScript' },
{ id: 6, name: 'Next.js', category: 'Frontend', description: 'React framework with file-based routing and SSR' },
{ id: 7, name: 'Express', category: 'Backend', description: 'Minimal Node.js web framework' },
{ id: 8, name: 'Python', category: 'Language', description: 'General-purpose language popular in data science' },
{ id: 9, name: 'MongoDB', category: 'Database', description: 'Document-oriented NoSQL database' },
{ id: 10, name: 'GraphQL', category: 'Backend', description: 'Query language for APIs with a type system' },
]
export default function TechSearch() {
const [category, setCategory] = useState<Category>('All')
const [query, setQuery] = useState('')
const filtered = useMemo(
() => techStack
.filter(t => category === 'All' || t.category === category)
.filter(t => t.name.toLowerCase().includes(query.toLowerCase())),
[category, query]
)
return (
<>
<div style={{ display: 'flex', gap: 10 }} role="search">
<label htmlFor="category-filter" style={{ display: 'none' }}>Filter by category</label>
<select
id="category-filter"
value={category}
onChange={e => setCategory(e.target.value as Category)}
aria-label="Filter by category"
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<label htmlFor="name-filter" style={{ display: 'none' }}>Search by name</label>
<input
id="name-filter"
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search technologies..."
aria-label="Search by name"
/>
</div>
<p aria-live="polite" style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{filtered.length} {filtered.length === 1 ? 'result' : 'results'}
</p>
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Category</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody>
{filtered.length > 0
? filtered.map(t => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.category}</td>
<td>{t.description}</td>
</tr>
))
: (
<tr>
<td colSpan={3} style={{ textAlign: 'center', opacity: 0.6 }}>
No results for "{query}"
{category !== 'All' && ` in ${category}`}
</td>
</tr>
)
}
</tbody>
</table>
</>
)
}URL state — the question this component doesn’t answer. If a user filters by “Backend” and shares the URL, the recipient sees an empty filter. For any UI where a filtered view might be bookmarked or shared, the filter state belongs in the URL: ?category=Backend&q=express. In a Next.js or React Router app:
// URL state via search params — survives refresh, shareable, browser back/forward
const [searchParams, setSearchParams] = useSearchParams()
const category = (searchParams.get('category') ?? 'All') as Category
const query = searchParams.get('q') ?? ''
const setCategory = (c: Category) => {
setSearchParams(p => { p.set('category', c); return p })
}The rule: if the user might want to refresh, share, or back-navigate to a filtered view, the filter belongs in the URL, not in useState.
Live demo:
tech skills matching
| name | category | description |
|---|---|---|
| React | Frontend | UI library for building component-based interfaces |
| Vue | Frontend | Progressive framework for building UIs |
| Svelte | Frontend | Compiler that generates minimal framework-less code |
| Node.js | Backend | JavaScript runtime built on V8 engine |
| Express | Backend | Minimal web framework for Node.js |
| Django | Backend | High-level Python web framework |
| PostgreSQL | Database | Advanced open-source relational database |
| MongoDB | Database | Document-oriented NoSQL database |
| Redis | Database | In-memory data structure store |
| TypeScript | Language | Typed superset of JavaScript |
| Python | Language | General-purpose programming language |
| Rust | Language | Systems programming language focused on safety |
3. ContactForm
Concepts used: single-object form state, per-field error state, validation before submit, label associations, functional state updates
A contact form with inline validation. The primary engineering interest is in the submit event model and the relationship between validation and stale state.
The submit handler architecture problem. The naive version attaches onSubmit={(e) => e.preventDefault()} to the form and onClick={handleSubmit} to the button. This creates a split responsibility: the form prevents default but doesn’t validate; the button validates but isn’t connected to the form’s submit event. Pressing Enter in an input field submits the form (triggering onSubmit) but never runs handleSubmit. The correct pattern puts everything in onSubmit:
// Wrong — Enter key bypasses validation
<form onSubmit={(e) => e.preventDefault()}>
<button onClick={handleSubmit}>Submit</button>
// Correct — single submit handler handles all submission paths
<form onSubmit={handleSubmit}>
<button type="submit">Submit</button>Functional updates for the change handler. The original handleInput reads data from its closure. If two fields change in rapid succession before re-render, the second update overwrites the first. Functional updates always compose:
// Stale closure — second rapid update may lose first
const handleInput = (e: FormEvent) =>
setData({ ...data, [e.target.name]: e.target.value })
// Correct — always reads latest committed state
const handleInput = (e: FormEvent) =>
setData(prev => ({ ...prev, [e.target.name]: e.target.value }))Structured error state — matching field names:
import { useState } from 'react'
interface FormData {
name: string
email: string
message: string
}
// Error keys mirror FormData keys — makes mapping errors to fields mechanical
type FormErrors = Partial<Record<keyof FormData, string>>
type FormEvent = React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
const INITIAL: FormData = { name: '', email: '', message: '' }
function validate(data: FormData): FormErrors {
const errors: FormErrors = {}
if (!data.name.trim()) errors.name = 'Name is required'
if (!data.email.trim()) errors.email = 'Email is required'
else if (!data.email.includes('@')) errors.email = 'Email must include @'
if (!data.message.trim()) errors.message = 'Message is required'
return errors
}
export default function ContactForm() {
const [data, setData] = useState<FormData>(INITIAL)
const [errors, setErrors] = useState<FormErrors>({})
const [submitted, setSubmitted] = useState(false)
const handleInput = (e: FormEvent) => {
const { name, value } = e.target
setData(prev => ({ ...prev, [name]: value }))
// Clear the error for this field as the user types
if (errors[name as keyof FormData]) {
setErrors(prev => ({ ...prev, [name]: undefined }))
}
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// Compute errors synchronously — don't read state after setErrors, it will be stale
const newErrors = validate(data)
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
console.log('Submitted:', data)
setSubmitted(true)
}
if (submitted) {
return <p role="status" style={{ color: 'green' }}>Message sent successfully.</p>
}
const fields: Array<{ key: keyof FormData; label: string; type?: string; rows?: number }> = [
{ key: 'name', label: 'Name', type: 'text' },
{ key: 'email', label: 'Email', type: 'email' },
{ key: 'message', label: 'Message', rows: 4 },
]
return (
<form onSubmit={handleSubmit} noValidate>
{fields.map(({ key, label, type, rows }) => (
<div key={key} style={{ marginBottom: '1rem' }}>
<label htmlFor={key}>{label}</label>
{rows ? (
<textarea
id={key}
name={key}
value={data[key]}
onChange={handleInput}
rows={rows}
aria-describedby={errors[key] ? `${key}-error` : undefined}
aria-invalid={!!errors[key]}
required
/>
) : (
<input
id={key}
name={key}
type={type}
value={data[key]}
onChange={handleInput}
autoComplete={key === 'email' ? 'email' : key === 'name' ? 'name' : undefined}
aria-describedby={errors[key] ? `${key}-error` : undefined}
aria-invalid={!!errors[key]}
required
/>
)}
{errors[key] && (
<p id={`${key}-error`} role="alert" style={{ color: 'red', margin: '0.25rem 0 0' }}>
{errors[key]}
</p>
)}
</div>
))}
<button type="submit">Send Message</button>
</form>
)
}Four accessibility details that change assistive technology behavior:
aria-invalid={!!errors[key]} signals to screen readers that the field is in an error state — announced when the user focuses the field.
aria-describedby pointing to the error element’s id (formatted as name-error, email-error, etc.) associates the error message with the input. Screen readers read the label, then the value, then the described-by content — so the error is announced as part of the field’s identity.
role="alert" on the error paragraph causes the error to be announced immediately when it appears, even if the user’s focus is elsewhere.
noValidate disables native browser validation UI so you control the error presentation. The required attribute is kept for semantic correctness and for non-JS contexts, but the browser won’t show its default validation popover.
Live demo: