Skip to main content
Back to Articles
TypeScript

TypeScript Best Practices for React Applications in 2024

Learn the essential TypeScript patterns and best practices that will make your React applications more maintainable, type-safe, and scalable.

WH Studio logo
WH Studio

Product Engineering Studio

100+ Projects
15+ Countries
2025-11-06T05:34:32.178495+00:00
12 min read
Share:
TypeScript Best Practices for React Applications in 2024

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 returns T | undefined. Catches an entire category of "it works locally" bugs.
  • "exactOptionalPropertyTypes": true — distinguishes "property is absent" from "property is undefined". Matters more than it sounds.
  • "noImplicitOverride": true
  • "noFallthroughCasesInSwitch": true
  • "verbatimModuleSyntax": true — keeps import type honest, 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 interface for public component props (better error messages, declaration merging if you ever need it).
  • Use type for unions, intersections, and utility transforms.
  • Skip React.FC. It adds nothing in modern React and used to imply implicit children.
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> vs React.MutableRefObject<number>
  • React.ComponentPropsWithoutRef<"button"> to extend a native element
  • React.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: params and searchParams are 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

  • any as a type annotation. Use unknown if you genuinely don't know — it forces narrowing.
  • as casts to silence the compiler. They are almost always hiding a real bug. Use a type guard or a parser instead.
  • @ts-ignore and @ts-expect-error without 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.ts co-located with the component.

11. The Lint and Tooling Layer

A typed codebase still needs lint:

  • typescript-eslint with the strict-type-checked preset.
  • eslint-plugin-react-hooks — non-negotiable; it catches an entire category of bugs that types cannot.
  • eslint-plugin-jsx-a11y for accessibility regressions.
  • prettier for 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:

  1. Turn on "strict": true and "noUncheckedIndexedAccess": true. Count the errors.
  2. 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.
  3. Replace useState<any> and unknown props one module at a time. Don't try to type the whole codebase in one PR.
  4. Add tsc --noEmit to 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. book a consultation">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.FC for 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 const objects with a derived union type.
  • any as a "temporary" type. It's never temporary. unknown forces you to narrow at the boundary, which is what you wanted anyway.
  • Type assertions over type guards. as User skips the check; a isUser(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 stays User.
  • 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.

UK Businesses Only

Let's Build Something Exceptional Together

Complimentary technical audit & consultation
Personalized roadmap for your business goals
Zero commitment 24-hour response time
Trusted by 50+ UK businesses
GDPR Compliant 98% Satisfaction Rate

Continue Reading

Explore related insights and strategies

1
Career
London, UK
8 min read

The Price of Belonging

When choosing your tech stack becomes choosing your future. Discover the most lucrative tech stacks in London's competitive market and understand which skills command the highest salaries in 2026.

Jan 15, 2026
2
Technical
Global
12 min read

Full Stack Development Best Practices 2026: Build Better, Faster, Smarter

Master modern full-stack development with proven best practices covering architecture, security, performance, and scalability. Learn from real-world production experience.

Jan 22, 2026
3
Career
Manchester, UK
6 min read

Manchester's Developer Gold Rush: The New Tech Hub

Why Manchester is becoming the UK's fastest-growing tech hub. Explore the opportunities, salaries, and lifestyle that's attracting developers from London.

Jan 10, 2026
Limited Availability - UK Businesses Only

Your Next Project Deserves Expert Execution

Partner with a proven full-stack developer who's delivered 100+ successful projects across fintech, healthcare, and SaaS. Let's discuss your vision in a free 30-minute strategy session.

100+
Projects Delivered
15+
Countries Served
98%
Client Satisfaction
24h
Response Time
30-minute consultation
No commitment required
Actionable insights
JD
SM
AL

"Exceptional technical expertise and delivery. Transformed our legacy system into a modern, scalable platform."

Join 50+ satisfied UK businesses