React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

best-practicessummarytypescript-react

TypeScript + React Best Practices

A condensed summary of the 25 most important best practices drawn from every page in this section.

  1. Enable strict Plus Extra Flags: 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.
  2. Use jsx: preserve, Not jsx: react: Set "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.
  3. Export From declare global Files: A .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.
  4. Validate env Vars at Runtime: Augmenting 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.
  5. Switch on props.variant, Don't Destructure: Discriminated-union narrowing depends on the path 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.
  6. 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.
  7. Use <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.
  8. Avoid React.FC for Generics and Async: 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.
  9. Treat Every ! Non-Null Assertion as a Bug: Under strict null checks, every 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.
  10. Destructure Tuples to Keep Their Types: 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.
  11. 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.
  12. Assertion Functions Must Throw: A function typed with 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.
  13. Zod-Parse API Responses: 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.
  14. Check response.ok Explicitly: 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().
  15. 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.
  16. Prefer event.currentTarget Over event.target: 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(...).
  17. Extend DOM Props With ComponentPropsWithoutRef: Type wrapper components with React.ComponentPropsWithoutRef<"input"> (or WithRef when forwarding) so every native attribute stays in sync with the DOM automatically, instead of redeclaring disabled, onChange, etc.
  18. children Is ReactNode, Not JSX.Element: 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.
  19. Type Timer Refs With ReturnType: 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.
  20. Await params in Next.js 15: Route handlers and page props now receive 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]".
  21. satisfies Your JSON Responses: Use 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.
  22. 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, and Symbol cannot cross, and passing them throws a serialization error at render.
  23. Server Components Can Be async, Client Cannot: Async function components and direct 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.
  24. Always Initialize useState: 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.
  25. 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 remember Partial and Readonly are shallow, so use a custom DeepPartial/DeepReadonly for nested updates.