TypeScript in React: Patterns That Actually Matter

Six weeks into the insurance CRM project, we had a production incident. The backend team renamed a field — policy_number became policyNumber — as part of a snake_case-to-camelCase API migration. The migration happened on a Friday. By Monday morning, the policy list was blank for every user.
The bug took four minutes to find. The cause took longer to accept: we had typed the API response as any. Not carelessly — the original developer had written a comment: "TODO: add proper types." The TODO was six months old.
Honest answer: I've seen this exact scenario more times than I want to count. TypeScript installed, TypeScript ignored at the boundaries. any at the API layer, loose props in the middle, and full type safety only in the parts that didn't need it — utility functions, helpers, things that already worked.
This article is not a TypeScript tutorial. If you need to understand generics from first principles, there are better resources for that. What I want to cover are the specific patterns that prevent real production bugs in React + TanStack apps. The ones I reach for on every project now, not as best practices, but because I've seen what happens without them.
Pattern 1: The Typed API Layer
The most important rule: type your API responses at the boundary, once.
Not in the component. Not in the hook. At the fetch function that talks to the API. Everything downstream inherits the types automatically.
// src/api/policies.ts import type { Policy, PolicyDetail } from '../types/policy'; export async function fetchPolicies(): Promise<Policy[]> { const res = await fetch('/api/policies'); if (!res.ok) throw new Error(`GET /api/policies failed: ${res.status}`); return res.json() as Promise<Policy[]>; } export async function fetchPolicyById(policyId: string): Promise<PolicyDetail> { const res = await fetch(`/api/policies/${policyId}`); if (!res.ok) throw new Error(`GET /api/policies/${policyId} failed: ${res.status}`); return res.json() as Promise<PolicyDetail>; }
// src/types/policy.ts export interface Policy { id: string; policyNumber: string; holderName: string; status: 'active' | 'expired' | 'pending'; effectiveDate: string; } export interface PolicyDetail extends Policy { coverageType: string; premiumAmount: number; beneficiaries: Beneficiary[]; } export interface Beneficiary { id: string; name: string; relationship: string; percentage: number; }
Now useQuery({ queryFn: fetchPolicies }) returns Policy[] — typed, autocompleted, checked at every usage. When the backend changes policyNumber to something else, TypeScript tells you before production.
The as Promise<Policy[]> cast on res.json() is intentional. res.json() returns Promise<unknown> in recent TypeScript. We're making an explicit contract: this fetch function guarantees this shape. If you want runtime validation as well, add Zod:
import { z } from 'zod'; const PolicySchema = z.object({ id: z.string(), policyNumber: z.string(), holderName: z.string(), status: z.enum(['active', 'expired', 'pending']), effectiveDate: z.string(), }); export async function fetchPolicies(): Promise<Policy[]> { const res = await fetch('/api/policies'); if (!res.ok) throw new Error(`GET /api/policies failed: ${res.status}`); const raw = await res.json(); return z.array(PolicySchema).parse(raw); }
With Zod, a field rename or unexpected shape becomes a runtime error with a descriptive message, not a blank screen with no useful stack trace. Article #4 covers error handling strategies — for now, know this option exists.
Pattern 2: Query Key Factories
Query keys in TanStack Query are arrays. They look harmless until you have fifty queries and someone types ['policy', policyId] in one file and ['policies', id] in another. Invalidation stops working silently.
The fix is a query key factory — one object that owns all keys for a domain:
// src/api/query-keys.ts export const policyKeys = { all: ['policies'] as const, lists: () => [...policyKeys.all, 'list'] as const, list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const, details: () => [...policyKeys.all, 'detail'] as const, detail: (id: string) => [...policyKeys.details(), id] as const, };
Usage:
// In PolicyListPage useQuery({ queryKey: policyKeys.list({ status: 'active' }), queryFn: () => fetchPolicies({ status: 'active' }), }); // In mutation after update queryClient.invalidateQueries({ queryKey: policyKeys.lists() }); // In PolicyDetailPage useQuery({ queryKey: policyKeys.detail(policyId), queryFn: () => fetchPolicyById(policyId), });
The as const assertions make the arrays readonly tuples instead of (string | PolicyListFilters)[], which means TypeScript knows the exact structure. When you call invalidateQueries({ queryKey: policyKeys.lists() }), it correctly invalidates all list queries regardless of their filter arguments, because the key hierarchy matches.
This is a pattern I keep coming back to. Centralizing keys also makes it trivial to audit which queries exist in an application — you read one file.
Pattern 3: Discriminated Unions for Async State
The classic approach to async state in React:
const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null);
This is three independent booleans representing one state machine. The problem: you can have loading: true and data: something at the same time. TypeScript can't tell you that's impossible. Your component has to handle combinations that shouldn't exist.
TanStack Query solves this with its own state machine, but when you need custom async state — or when you want to model UI states explicitly — discriminated unions are the right tool:
// src/types/async-state.ts export type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T };
Usage in a component:
function PolicyDetail({ policyId }: { policyId: string }) { const query = useQuery({ queryKey: policyKeys.detail(policyId), queryFn: () => fetchPolicyById(policyId), }); if (query.isPending) return <PolicyDetailSkeleton />; if (query.isError) return <ErrorMessage error={query.error} />; // TypeScript now knows query.data is PolicyDetail — not PolicyDetail | undefined const policy = query.data; return ( <div> <h1>{policy.policyNumber}</h1> <p>{policy.holderName}</p> </div> ); }
After the isError guard, TypeScript narrows query.data to the non-undefined type. This is TanStack Query's built-in discriminated union working correctly. The principle applies when you build your own async state machines: model them as discriminated unions from the start, and TypeScript narrows automatically when you check status.
TypeScript's type narrowing is only as good as your state model. If your state model allows impossible combinations, no amount of type annotations fixes that.
Pattern 4: Typed Route Params with TanStack Router
This is where TanStack Router pays for itself. In article #1, we set up the router with a Register declaration. Here's what that gives you:
// src/pages/PolicyDetailPage.tsx import { useParams } from '@tanstack/react-router'; export function PolicyDetailPage() { // policyId is string — typed against the route definition // TypeScript error if this route doesn't have $policyId const { policyId } = useParams({ from: '/policies/$policyId' }); // ... }
Compare to React Router:
// React Router — manual cast, no safety const { policyId } = useParams<{ policyId: string }>();
The React Router version compiles even if the route doesn't exist. The TanStack Router version fails to compile if the route doesn't have $policyId. That's the difference between type annotations and type inference.
Search params work the same way, but you need to define a validator on the route:
// src/router.ts import { z } from 'zod'; const policyListSearchSchema = z.object({ status: z.enum(['active', 'expired', 'pending']).optional(), page: z.number().int().positive().optional().default(1), }); const policyListRoute = createRoute({ getParentRoute: () => rootRoute, path: '/policies', validateSearch: policyListSearchSchema, component: PolicyListPage, });
// In PolicyListPage const { status, page } = useSearch({ from: '/policies' }); // status: 'active' | 'expired' | 'pending' | undefined // page: number (default 1)
Search params validated with Zod, typed at the component level, consistent across navigation. No URLSearchParams parsing. No manual type assertions. Navigating to /policies?status=invalid either throws at the router level or falls back to the default — you control the behavior.
Pattern 5: Generic Data Components
React components that render lists of things are everywhere. The naive approach writes a new component for every list. The slightly better approach passes items as any[]. The right approach uses generics:
// src/components/DataList.tsx interface DataListProps<T> { items: T[]; getKey: (item: T) => string; renderItem: (item: T) => React.ReactNode; emptyMessage?: string; } export function DataList<T>({ items, getKey, renderItem, emptyMessage = 'No items found', }: DataListProps<T>) { if (items.length === 0) { return <p className="text-muted-foreground">{emptyMessage}</p>; } return ( <ul className="space-y-2"> {items.map((item) => ( <li key={getKey(item)}>{renderItem(item)}</li> ))} </ul> ); }
Usage:
<DataList items={policies} getKey={(p) => p.id} renderItem={(p) => <PolicyRow policy={p} />} emptyMessage="No policies match this filter" />
TypeScript infers T as Policy from the items prop. The getKey and renderItem functions are typed to Policy automatically. You can't pass a Beneficiary to renderItem by accident.
This pattern works for any container component: tables, grids, selects, autocompletes. Write it once, use it everywhere, get full type safety for free.
Pattern 6: Typed Context
React Context is commonly the place where types collapse to any or to T | undefined that requires a null check at every usage. A cleaner pattern:
// src/context/auth-context.tsx interface AuthContextValue { userId: string; role: 'admin' | 'agent' | 'viewer'; logout: () => void; } const AuthContext = React.createContext<AuthContextValue | null>(null); export function useAuth(): AuthContextValue { const ctx = React.useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used within AuthProvider'); return ctx; }
The context itself accepts null (before the provider mounts). The useAuth hook throws if used outside the provider and returns the non-null type. Every component that calls useAuth() gets AuthContextValue without optional chaining. The invariant is checked once, at the hook boundary.
This is not a new pattern. It's in the React docs. But I keep seeing codebases where context values are T | undefined and components are littered with auth?.userId ?? ''. The throw-in-the-hook approach pushes the validation to the right place and cleans up every callsite.
What to Carry Forward
These six patterns cover the majority of TypeScript friction I encounter in React codebases:
- Typed API layer — type once at the boundary, inherit everywhere
- Query key factories — centralize keys, prevent invalidation bugs
- Discriminated unions — model states correctly, let TypeScript narrow
- TanStack Router inference — route params typed against actual routes, not manual casts
- Generic components — one implementation, full type safety across all usages
- Typed context with throwing hooks — clean callsites, invariant checked once
None of these require advanced TypeScript knowledge. They require the discipline to type at boundaries instead of in the middle, and to model state as what it actually is rather than what's convenient.
Six months after that production incident, the insurance CRM had a typed API layer across every endpoint and Zod validation on the most critical ones. We had two more backend field renames during that time. Neither reached production. TypeScript caught both during development, before the PRs were even reviewed.
That's the whole point — not type coverage as a metric, but fewer incidents as an outcome.
If you're working through TypeScript patterns in a React codebase and hitting specific friction points — reach out. These problems are usually more mechanical than they appear.