EmpireUI
Get Pro
← Blog8 min read#react#typescript#types

React + TypeScript in 2026: 12 Patterns Senior Devs Swear By

The React + TypeScript patterns that actually matter in 2026 — from discriminated unions to satisfies, 12 techniques senior devs reach for every day.

Developer typing React TypeScript code on a laptop screen

Why These Patterns and Not the Usual Blog List

Let's skip the preamble. You already know TypeScript catches bugs at compile time. You've read that a dozen times. What you probably haven't seen is a list that cuts out the basics and goes straight to the patterns that separate 'it compiles' from 'this is actually maintainable in 18 months.'

These 12 patterns come from real codebases — production apps, open-source components in Empire UI, and teams that were actively trying to reduce their on-call alerts. Some of them you'll recognize. A few might surprise you.

Honestly, the biggest shift in 2026 isn't a new React feature — it's that TypeScript 5.x tooling is finally fast enough that you won't hesitate to add more precise types. That friction is gone. No excuse now.

Discriminated Unions for Component Variants

This is pattern #1 for a reason. If you've got a Button that can be either a link or a regular button, stop using optional props and start using discriminated unions. The difference is immediate when something breaks — the compiler tells you exactly which variant you're violating.

type ButtonBase = {
  label: string;
  size?: 'sm' | 'md' | 'lg';
};

type ButtonAsButton = ButtonBase & {
  as: 'button';
  onClick: () => void;
  href?: never;
};

type ButtonAsLink = ButtonBase & {
  as: 'link';
  href: string;
  onClick?: never;
};

type ButtonProps = ButtonAsButton | ButtonAsLink;

export function Button(props: ButtonProps) {
  if (props.as === 'link') {
    return <a href={props.href}>{props.label}</a>;
  }
  return <button onClick={props.onClick}>{props.label}</button>;
}

The never on conflicting props is the key move here. You'll catch a huge class of copy-paste bugs — like when someone passes both href and onClick — at compile time instead of in a Sentry report at 2am.

Worth noting: this pattern scales beautifully to design systems. Every component in a well-typed library uses some version of this. The glassmorphism components we ship at Empire UI are typed this way internally.

The satisfies Operator Is Still Underused

TypeScript 4.9 shipped satisfies back in 2022 and people are still not reaching for it enough in 2026. Short version: it validates that a value matches a type without widening the inferred type. That's a real distinction.

type Theme = {
  colors: Record<string, string>;
};

// Without satisfies — colors is Record<string, string>, autocomplete is useless
const theme: Theme = {
  colors: { primary: '#7C3AED', background: '#0F0F0F' }
};

// With satisfies — colors is { primary: string; background: string }
// You get autocomplete AND type safety
const theme2 = {
  colors: { primary: '#7C3AED', background: '#0F0F0F' }
} satisfies Theme;

// theme2.colors.primary works — theme.colors.primary errors

In practice, this is most useful for config objects, theme tokens, and route maps. The moment you have a large object literal where you want both validation and narrow inference, satisfies is the answer.

One more thing — satisfies pairs brilliantly with as const. Use both when you're defining static configuration and you want the type system to track the exact values, not just their shape.

Generic Components Without the Pain

Generic components are where a lot of developers tap out and reach for any. Don't. The syntax is awkward at first, but once you've written three or four of them it clicks.

type ListProps<T> = {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
};

export function List<T>({
  items,
  renderItem,
  keyExtractor,
}: ListProps<T>) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
      ))}
    </ul>
  );
}

// Usage — T is inferred as { id: string; name: string }
<List
  items={users}
  keyExtractor={(u) => u.id}
  renderItem={(u) => <span>{u.name}</span>}
/>

The inference here is what makes it worthwhile. You don't annotate the call site — TypeScript figures out T from items. That's the pattern. Keep T constrained if you need to: <T extends { id: string }> gets you property access inside the component without losing generics.

Quick aside: in TSX files, write <T,> instead of <T> to avoid the parser treating it as JSX. That trailing comma is a real gotcha for newcomers.

Custom Hooks With Precise Return Types

Here's a pattern that's simple but frequently wrong: return types on custom hooks. Specifically, returning a tuple. Without an explicit annotation, TypeScript infers an array type instead of a tuple — and then const [value, setValue] = useMyHook() has the wrong types.

function useToggle(initial = false): [boolean, () => void] {
  const [on, setOn] = React.useState(initial);
  const toggle = React.useCallback(() => setOn(v => !v), []);
  return [on, toggle];
}

That return annotation — [boolean, () => void] — is load-bearing. Without it, TypeScript sees (boolean | (() => void))[] and the destructured toggle becomes boolean | (() => void). You'll spend ten minutes confused about why calling it errors.

Alternatively, use as const on the return: return [on, toggle] as const. Both work. The explicit annotation is clearer for anyone reading the function signature in isolation.

Look, this comes up in every React codebase. It's a 30-second fix and it prevents a whole category of incorrect type narrowing downstream.

Context Without the useContext Null Dance

Every React developer has written the const ctx = useContext(MyContext); if (!ctx) throw new Error('...') pattern at least fifty times. There's a cleaner way. Wrap it once, type it properly, and never write that null check again.

function createCtx<T>() {
  const Ctx = React.createContext<T | undefined>(undefined);

  function useCtx() {
    const value = React.useContext(Ctx);
    if (value === undefined) {
      throw new Error('useCtx must be used within its Provider');
    }
    return value;
  }

  return [Ctx.Provider, useCtx] as const;
}

// Usage
type AuthState = { userId: string; role: 'admin' | 'user' };
const [AuthProvider, useAuth] = createCtx<AuthState>();

// useAuth() always returns AuthState — never undefined

This factory pattern is from 2021 but it's aged perfectly. The returned hook is fully typed, the provider is correctly typed, and you've written the null check exactly once. If your team isn't using something like this, you're writing a lot of redundant boilerplate.

That said, if you're on a larger project consider Zustand or Jotai for global state instead of Context — the re-render behavior at scale is just better. Context is great for dependency injection and UI config (themes, locale), not for frequently-updating state.

Conditional Types, Template Literals, and the Patterns Worth Your Time

The last few patterns are more advanced but show up constantly in design system and component library work. Conditional types let you compute types from other types — and paired with template literal types, you get genuinely expressive APIs.

// Generate CSS custom property names from a token object
type TokenKey = 'primary' | 'secondary' | 'surface';
type CSSVar<T extends string> = `--color-${T}`;
type ColorVars = CSSVar<TokenKey>;
// type ColorVars = '--color-primary' | '--color-secondary' | '--color-surface'

// Extract only the optional keys from a type
type OptionalKeys<T> = {
  [K in keyof T]-?: undefined extends T[K] ? K : never;
}[keyof T];

You'd use these in a gradient generator or any tool where you're mapping design tokens to CSS output. The template literal types especially shine when you need to validate string shapes — like checking that a prop is a valid Tailwind class prefix.

Honestly, don't reach for conditional types unless you're building abstractions that other components consume. Overusing them in application code is a maintenance burden. Use them in library code, in utility type helpers, in the places where the complexity is justified by DRY gains.

That covers the major patterns. Discriminated unions, satisfies, generic components, precise hook return types, a solid context factory, and conditional types where they earn their complexity. Master these and you'll write TypeScript that holds up — not just TypeScript that compiles. Browse Empire UI to see these patterns applied to real UI components if you want a reference implementation.

FAQ

Should I annotate all component props explicitly or let TypeScript infer them?

Annotate props explicitly with a named type or interface — it documents intent and surfaces mismatches at the call site. Inference is fine for internal variables inside the component body.

When does it make sense to use `unknown` instead of `any`?

Use unknown whenever you're dealing with external data (API responses, user input, parsed JSON). It forces you to narrow the type before using it, which is the whole point. Reserve any for migration escape hatches, not new code.

Is the `satisfies` operator supported in all major bundlers?

Yes — it's been in TypeScript since 4.9 (late 2022) and every modern bundler with TS support handles it. Vite, Next.js, and plain tsc all work fine. Check you're on TS 4.9+ in your tsconfig.

Do these patterns work with React Server Components in Next.js 15+?

Most do. Generic components, discriminated unions, and satisfies are pure TypeScript — no runtime dependency. Just remember that hooks (including custom ones) only run in Client Components, so the context factory and toggle hook patterns need the 'use client' directive.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Architecture & Patterns: The Complete 2026 Guide15 Custom React Hooks That Will Save You Hundreds of LinesTypeScript vs JavaScript in 2026: Is TS Still Worth the Setup?Tailwind vs CSS Modules in 2026: Which One Should You Actually Use?