TypeScript + React Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
Search across all documentation pages
A condensed summary of the 25 most important best practices drawn from every page in this section.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
strict: true is a family of flags but does not include noUncheckedIndexedAccess or exactOptionalPropertyTypes - turn those on explicitly so bracket access returns T | undefined and { theme: undefined } is not silently allowed on optional props."jsx": "preserve" (or "react-jsx") and "moduleResolution": "bundler" so you don't have to import React in every file and so package.json exports conditions resolve correctly..d.ts file using declare global { … } only participates in module merging if the file itself is treated as a module; add a trailing export {} or the global augmentations silently vanish.ProcessEnv in a .d.ts gives compile-time autocomplete but zero runtime guarantee, so parse process.env through a Zod schema (or a requireEnv helper) at startup so missing vars fail fast.props.variant, so destructuring const { variant } = props severs the link and breaks the narrowing; switch directly on props.variant to keep each case's fields typed.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.<T,> in Arrow Generics: In .tsx files, 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.React.FC cannot carry a generic type parameter and cannot be async, so write function List<T>(props: …) or async function Page() as plain function declarations for generic and Server Components.value! is a potential runtime TypeError, so prefer optional chaining, early returns, or a custom assertion function that actually throws with a message you can debug.noUncheckedIndexedAccess also applies to tuples via bracket access, so tuple[0] becomes T | undefined; destructuring const [a, b] = tuple preserves the known element types without the union.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.asserts x is T narrows all subsequent code in the caller, but TypeScript trusts you - if the function returns false instead of throwing, every downstream type is silently wrong.response.json() returns Promise<any> and as T casting 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.fetch does not throw on 4xx or 5xx - it only rejects on network failure - so always branch on response.ok and treat non-2xx as errors before calling .json().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.event.target is typed as the broad EventTarget, while event.currentTarget carries the generic element type (HTMLInputElement, HTMLFormElement), so use currentTarget for .value, .select(), or new FormData(...).React.ComponentPropsWithoutRef<"input"> (or WithRef when forwarding) so every native attribute stays in sync with the DOM automatically, instead of redeclaring disabled, onChange, etc.JSX.Element narrows to a single element and rejects strings, numbers, arrays, and null; use React.ReactNode for children so consumers can pass any renderable content without fighting the type.setInterval returns a number in browsers and a Timeout object in Node - const timer = useRef<ReturnType<typeof setInterval> | null>(null) - so this pattern keeps the code portable between SSR and the client.params and searchParams as Promises, so const { id } = await context.params - destructuring without await leaves you with a Promise object that silently stringifies to "[object Promise]".NextResponse.json({ error: "not found" } satisfies ApiErrorResponse) so the return value must match your response contract without widening; wrap request.json() (typed Promise<any>) in Zod validation so clients cannot send arbitrary payloads.Date, FormData, typed arrays, or Server Actions; functions, class instances, Map/Set, and Symbol cannot cross, and passing them throws a serialization error at render.await are legal only in Server Components - a Client Component written as async function compiles but throws at runtime, and TypeScript does not flag it, so enforce the split manually or with lint rules.useState<User>() with no initial value widens to User | undefined without warning - use useState<User | null>(null) to be explicit, or provide an initial value; the setter replaces state entirely, it does not merge like class setState.Omit<T, "nonExistent"> silently returns the full type because TypeScript widens the key parameter, so typos pass through unnoticed - also remember Partial and Readonly are shallow, so use a custom DeepPartial/DeepReadonly for nested updates.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥