TypeScript + React Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Enable strict Plus Extra Flags:
strict: trueis a family of flags but does not includenoUncheckedIndexedAccessorexactOptionalPropertyTypes— turn those on explicitly so bracket access returnsT | undefinedand{ theme: undefined }is not silently allowed on optional props. - Use jsx: preserve, Not jsx: react: Set
"jsx": "preserve"(or"react-jsx") and"moduleResolution": "bundler"so you don't have toimport Reactin every file and so package.jsonexportsconditions resolve correctly. - Export From declare global Files: A
.d.tsfile usingdeclare global { … }only participates in module merging if the file itself is treated as a module; add a trailingexport {}or the global augmentations silently vanish. - Validate env Vars at Runtime: Augmenting
ProcessEnvin a.d.tsgives compile-time autocomplete but zero runtime guarantee, so parseprocess.envthrough a Zod schema (or arequireEnvhelper) at startup so missing vars fail fast. - Switch on props.variant, Don't Destructure: Discriminated-union narrowing depends on the path
props.variant, so destructuringconst { variant } = propssevers the link and breaks the narrowing; switch directly onprops.variantto keep each case's fields typed. - Use assertNever for Exhaustive Switches: End discriminated-union switches with
default: return assertNever(action)—function assertNever(x: never): never { throw new Error("Unexpected: " + x) }— so adding a new variant fails the type-check instead of falling through silently. - Use
<T,>in Arrow Generics: In.tsxfiles, arrow-function generics collide with JSX syntax, so write<T,>(trailing comma) or<T extends unknown>to disambiguate the generic parameter from an element tag. - Avoid React.FC for Generics and Async:
React.FCcannot carry a generic type parameter and cannot be async, so writefunction List<T>(props: …)orasync function Page()as plain function declarations for generic and Server Components. - Treat Every ! Non-Null Assertion as a Bug: Under strict null checks, every
value!is a potential runtimeTypeError, so prefer optional chaining, early returns, or a custom assertion function that actually throws with a message you can debug. - Destructure Tuples to Keep Their Types:
noUncheckedIndexedAccessalso applies to tuples via bracket access, sotuple[0]becomesT | undefined; destructuringconst [a, b] = tuplepreserves the known element types without the union. - Prefer satisfies Over Type Annotations:
const routes = { home: "/", about: "/about" } satisfies Record<string, string>validates shape without widening literal types, so keys keep their exact"home" | "about"types for downstream lookups — a plain: Record<string, string>annotation throws that away. - Assertion Functions Must Throw: A function typed with
asserts x is Tnarrows all subsequent code in the caller, but TypeScript trusts you — if the function returnsfalseinstead of throwing, every downstream type is silently wrong. - Zod-Parse API Responses:
response.json()returnsPromise<any>andas Tcasting provides zero runtime protection; pipe responses through a Zod schema —const user = UserSchema.parse(await res.json())— so the validation and the type come from a single source of truth. - Check response.ok Explicitly:
fetchdoes not throw on 4xx or 5xx — it only rejects on network failure — so always branch onresponse.okand treat non-2xx as errors before calling.json(). - Context Default null Plus Guard Hook: Prefer
createContext<T | null>(null)with a guard hook —function useAuth() { const ctx = useContext(AuthCtx); if (!ctx) throw new Error("Missing AuthProvider"); return ctx }— so missing-provider bugs surface immediately instead of crashing on the first property access. - Prefer event.currentTarget Over event.target:
event.targetis typed as the broadEventTarget, whileevent.currentTargetcarries the generic element type (HTMLInputElement,HTMLFormElement), so usecurrentTargetfor.value,.select(), ornew FormData(...). - Extend DOM Props With ComponentPropsWithoutRef: Type wrapper components with
React.ComponentPropsWithoutRef<"input">(orWithRefwhen forwarding) so every native attribute stays in sync with the DOM automatically, instead of redeclaringdisabled,onChange, etc. - children Is ReactNode, Not JSX.Element:
JSX.Elementnarrows to a single element and rejects strings, numbers, arrays, andnull; useReact.ReactNodeforchildrenso consumers can pass any renderable content without fighting the type. - Type Timer Refs With ReturnType:
setIntervalreturns anumberin browsers and aTimeoutobject in Node —const timer = useRef<ReturnType<typeof setInterval> | null>(null)— so this pattern keeps the code portable between SSR and the client. - Await params in Next.js 15: Route handlers and page props now receive
paramsandsearchParamsas Promises, soconst { id } = await context.params— destructuring withoutawaitleaves you with a Promise object that silently stringifies to"[object Promise]". - satisfies Your JSON Responses: Use
NextResponse.json({ error: "not found" } satisfies ApiErrorResponse)so the return value must match your response contract without widening; wraprequest.json()(typedPromise<any>) in Zod validation so clients cannot send arbitrary payloads. - Only Serializable Props Cross the Boundary: Server→Client prop values must be strings, numbers, plain objects,
Date,FormData, typed arrays, or Server Actions; functions, class instances,Map/Set, andSymbolcannot cross, and passing them throws a serialization error at render. - Server Components Can Be async, Client Cannot: Async function components and direct
awaitare legal only in Server Components — a Client Component written asasync functioncompiles but throws at runtime, and TypeScript does not flag it, so enforce the split manually or with lint rules. - Always Initialize useState:
useState<User>()with no initial value widens toUser | undefinedwithout warning — useuseState<User | null>(null)to be explicit, or provide an initial value; the setter replaces state entirely, it does not merge like classsetState. - Omit Doesn't Error On Bad Keys:
Omit<T, "nonExistent">silently returns the full type because TypeScript widens the key parameter, so typos pass through unnoticed — also rememberPartialandReadonlyare shallow, so use a customDeepPartial/DeepReadonlyfor nested updates.