TypeScript applications Best Practices for React Applications in 2026
TypeScript in React stopped being optional somewhere around 2022. In 2026 the conversation has moved from "should we use it" to "are we using it well?" Most codebases we audit are technically typed but practically untyped — any sprinkled liberally, JSX props loosely typed, server boundaries untyped, and a tsconfig.json that was copied from a tutorial in 2021.
This is the playbook our team at WH Studio uses to make TypeScript actually pay for itself in production React applications.
1. The tsconfig That Should Be Your Default
If your tsconfig.json does not have all of these enabled, you are leaving safety on the table:
"strict": true— non-negotiable"noUncheckedIndexedAccess": true— array and object index access returnsT | undefined. Catches an entire category of "it works locally" bugs."exactOptionalPropertyTypes": true— distinguishes "property is absent" from "property isundefined". Matters more than it sounds."noImplicitOverride": true"noFallthroughCasesInSwitch": true"verbatimModuleSyntax": true— keepsimport typehonest, prevents accidental runtime imports.
Pair the compiler config with a CI step that runs tsc --noEmit on every PR. A type error that lands in main is a process failure, not a TypeScript failure.
2. Type Your Component Props the Boring Way
In 2026 the consensus is settled:
- Use
interfacefor public component props (better error messages, declaration merging if you ever need it). - Use
typefor unions, intersections, and utility transforms. - Skip
React.FC. It adds nothing in modern React and used to imply implicitchildren.
interface ButtonProps {
variant: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
export function Button({ variant, size = "md", onClick, children }: ButtonProps) {
return <button data-variant={variant} data-size={size} onClick={onClick}>{children}</button>;
}
For polymorphic components (as prop), reach for the well-known patterns from Radix and shadcn rather than rolling your own — getting them right is a multi-day adventure.
3. Make Illegal States Unrepresentable
The most underused TypeScript feature in React is the discriminated union. If your component can be in one of several mutually exclusive states, model them as such.
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
Now data cannot be accessed in the loading state, the compiler enforces handling every case, and the inevitable "why is data undefined here?" bug is impossible by construction. Replace every isLoading + error + data triplet you see in a codebase with one of these unions and bugs disappear.
4. Type the Server Boundary Once
The single most valuable type in a React app is the one describing what the server returns. Generate it; do not write it by hand.
- tRPC — full end-to-end type safety with zero schema files when client and server share a repo.
- OpenAPI codegen (
openapi-typescript,orval) when the API is REST and lives in a separate repo. - GraphQL Code Generator for GraphQL.
- Supabase / Prisma generated types for direct DB-backed apps.
Once the server type lives in code, every loader, query, and component that touches it gains the same guarantees. This is where TypeScript stops being decoration and starts saving real engineering hours.
5. Runtime Validation at the Edges
The boundary between your typed world and the messy outside is where types alone are not enough. At every untrusted input — API responses you don't generate, form submissions, environment variables, URL parameters — use a runtime validator that infers a TypeScript type.
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.coerce.date(),
});
export type User = z.infer<typeof UserSchema>;
export function parseUser(input: unknown): User {
return UserSchema.parse(input);
}
Zod, Valibot, Arktype, and Effect Schema are all production-grade choices. Pick one per codebase. The compiler protects you inside the function; the validator protects you at the boundary.
6. Hooks That Carry Their Own Types
Custom hooks are where type quality compounds. A few patterns we use everywhere:
Generic hooks
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = typeof window !== "undefined" ? window.localStorage.getItem(key) : null;
return raw ? (JSON.parse(raw) as T) : initial;
});
return [value, setValue] as const;
}
The as const tuple return is what makes destructuring const [count, setCount] = useCounter() infer correctly.
Constrained generics
When a hook only makes sense for objects with a specific shape, encode it:
function useEntityList<E extends { id: string }>(items: E[]) {
return items;
}
The compiler now rejects passing a string[] and gives autocomplete on item.id everywhere it's used.
7. Forms Are Where Most Codebases Lose Type Safety
A React form connects three layers — UI state, validation, and submission — that almost no library typed well until recently. In 2026 the answer is react-hook-form + zod via @hookform/resolvers:
const FormSchema = z.object({
email: z.string().email(),
age: z.number().int().min(18),
});
type FormValues = z.infer<typeof FormSchema>;
const { register, handleSubmit, formState } = useForm<FormValues>({
resolver: zodResolver(FormSchema),
});
register("email") is typed, formState.errors.email is typed, and handleSubmit gets a FormValues parameter. One schema, three correct types.
8. Events, Refs, and the JSX Types You Actually Need
The full list of React types most engineers reach for daily:
React.MouseEvent<HTMLButtonElement>/React.ChangeEvent<HTMLInputElement>/React.FormEvent<HTMLFormElement>React.RefObject<HTMLDivElement>vsReact.MutableRefObject<number>React.ComponentPropsWithoutRef<"button">to extend a native elementReact.PropsWithChildren<{ title: string }>when you genuinely need to add children to an existing props type
Memorising these five removes 80% of the "what's the type for this event?" friction.
9. Type-Safe Routing and Search Params
useNavigate and useSearchParams from react-router historically returned any. TanStack Router and the App Router patterns in Next.js fixed this. Whichever router you use, lean on the typed equivalents:
- TanStack Router: every route has typed params, search, and loader data inferred end-to-end.
- Next.js App Router:
paramsandsearchParamsare typed per route segment; use generated types if you can.
A typed router means a refactor that renames /users/[id] to /people/[id] becomes a compiler error in every link, not a midnight production page.
10. Anti-Patterns to Delete on Sight
anyas a type annotation. Useunknownif you genuinely don't know — it forces narrowing.ascasts to silence the compiler. They are almost always hiding a real bug. Use a type guard or a parser instead.@ts-ignoreand@ts-expect-errorwithout a comment explaining why. Ban them in lint.- Stringly-typed props (
status: string) where a literal union (status: "draft" | "published") would do. - Re-declaring the same prop type in three places. Hoist it to a
types.tsco-located with the component.
11. The Lint and Tooling Layer
A typed codebase still needs lint:
typescript-eslintwith thestrict-type-checkedpreset.eslint-plugin-react-hooks— non-negotiable; it catches an entire category of bugs that types cannot.eslint-plugin-jsx-a11yfor accessibility regressions.prettierfor formatting so reviewers stop arguing about us it.
Run the full type check + lint in CI on every PR. The cost is small; the cost of letting it slide is months of cleanup later.
12. Where to Start in an Untyped Codebase
If you have inherited a JavaScript or loosely-typed React codebase:
- Turn on
"strict": trueand"noUncheckedIndexedAccess": true. Count the errors. - Fix the boundaries first — API responses, environment variables, URL params. Most type pain in the interior of the codebase melts away once the edges are correct.
- Replace
useState<any>andunknownprops one module at a time. Don't try to type the whole codebase in one PR. - Add
tsc --noEmitto CI as a warning before you flip it to a failure. The team needs a quarter to absorb the discipline.
The goal is not perfection. It is to make the next type error caught at compile time instead of at 2am from a customer email.
Need help shipping this?
If your team wants the TypeScript discipline above embedded into a real production React codebase — without a six-month rewrite — our full-stack development and technology consulting teams do exactly this work. contact us">Book a call and we'll scope the smallest valuable engagement for your situation.
The patterns we'd argue against in 2026
Some TypeScript-React patterns that were popular in 2022 have aged poorly:
React.FCfor component typing. It implicitly types children, swallows generic constraints, and the React team itself moved away from it. Type props inline.- Enums. TypeScript enums emit runtime code, don't tree-shake well, and behave unintuitively with union narrowing. Use
as constobjects with a derived union type. anyas a "temporary" type. It's never temporary.unknownforces you to narrow at the boundary, which is what you wanted anyway.- Type assertions over type guards.
as Userskips the check; aisUser(x)guard does the check and narrows. The guard wins every time.
The compiler is part of your test suite
The most under-used capability of TypeScript is the type-level test. Tools like expect-type or tsd let you assert that your types behave correctly, in CI, with zero runtime cost. If you ship a library — internal or external — type-level tests catch breaking changes that no runtime test will.
A simple example:
expectType<User>(getUser('123'))confirms the return type staysUser.expectError(getUser(123))confirms passing a number stays a type error.
Pair this with tsc --noEmit in CI and you eliminate an entire class of regressions before they ship.
Where to invest typing effort
Not every line deserves the same care. The 80/20:
- Heavy typing at module boundaries — exported functions, API contracts, database schema (Drizzle/Zod). These are the surfaces other code depends on.
- Inferred typing inside modules — let TypeScript do the work for local variables, internal helpers, and return types of small functions.
- Strict typing for state machines and discriminated unions — these are where bugs hide.
Skip the typing theatre on UI glue code. Over-typing a <Button> prop spread is busywork; under-typing your API response shape is a production bug.
For a deeper conversation about TypeScript on your codebase, see our TypeScript development practice or get in touch.
