INS // Insights

React TypeScript Patterns for Production Apps

February 25, 2026 · 7 min read

Rutagon ships production React TypeScript applications that serve real users in demanding environments. House Escort — our commercial SaaS platform with native iOS and Android apps alongside a React web dashboard — processes real estate transactions where bugs are not inconveniences but liability risks. The React TypeScript patterns described here are not theoretical recommendations. They are the patterns we enforce in production code that runs every day.

This article covers the specific patterns that have proven their value across our production applications: strict TypeScript configurations that catch bugs at compile time, component composition patterns that scale, custom hooks that encapsulate complex logic, error boundaries that prevent cascading failures, and performance optimizations that keep interfaces responsive under load.

Strict TypeScript Configuration

The default TypeScript configuration is permissive. Production code requires stricter settings that surface type errors before they become runtime bugs.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true
  }
}

The noUncheckedIndexedAccess flag is particularly valuable. Without it, accessing an array element or object property by index returns T — with it, the return type is T | undefined, forcing you to handle the case where the value does not exist. This alone has prevented dozens of runtime crashes in House Escort's dashboard where API responses occasionally return fewer items than expected.

exactOptionalPropertyTypes distinguishes between a property being undefined and being absent entirely. This matters when working with DynamoDB responses where a missing attribute has different semantics than an attribute set to null.

React TypeScript Component Composition Patterns

Components should be composable, type-safe, and impossible to misuse. We achieve this through discriminated unions, polymorphic components, and strict prop interfaces.

Discriminated Unions for Variant Components

type ButtonProps =
  | {
      variant: 'primary';
      onClick: () => void;
      loading?: boolean;
      children: React.ReactNode;
    }
  | {
      variant: 'link';
      href: string;
      external?: boolean;
      children: React.ReactNode;
    };

function Button(props: ButtonProps) {
  if (props.variant === 'link') {
    return (
      <a
        href={props.href}
        target={props.external ? '_blank' : undefined}
        rel={props.external ? 'noopener noreferrer' : undefined}
        className="btn btn-link"
      >
        {props.children}
      </a>
    );
  }

  return (
    <button
      onClick={props.onClick}
      disabled={props.loading}
      className="btn btn-primary"
      aria-busy={props.loading}
    >
      {props.loading ? <Spinner size="sm" /> : props.children}
    </button>
  );
}

The discriminated union prevents invalid prop combinations at compile time. You cannot pass onClick to a link variant or href to a primary variant. TypeScript narrows the type inside each branch automatically.

Polymorphic Components with as Prop

type PolymorphicProps<E extends React.ElementType> = {
  as?: E;
  children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<E>, 'as' | 'children'>;

function Container<E extends React.ElementType = 'div'>({
  as,
  children,
  ...rest
}: PolymorphicProps<E>) {
  const Component = as || 'div';
  return <Component {...rest}>{children}</Component>;
}

// Usage — fully typed, including element-specific attributes
<Container as="section" aria-labelledby="heading-1">
  <h2 id="heading-1">Services</h2>
</Container>

<Container as="a" href="/contact">
  Contact Us
</Container>

This pattern lets a single component render as any HTML element while preserving type-safe access to that element's attributes. The Container rendered as an <a> accepts href; rendered as a <section>, it accepts aria-labelledby. This is the pattern behind our layout primitives in House Escort's design system.

Custom Hooks for Complex Logic

Custom hooks encapsulate stateful logic and side effects into reusable, testable units. The key constraint: hooks should do one thing and expose a clear, typed interface.

interface UseApiResult<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
  refetch: () => void;
}

function useApi<T>(endpoint: string, options?: RequestInit): UseApiResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const abortRef = useRef<AbortController | null>(null);

  const fetchData = useCallback(async () => {
    abortRef.current?.abort();
    abortRef.current = new AbortController();

    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(endpoint, {
        ...options,
        signal: abortRef.current.signal,
      });

      if (!response.ok) {
        throw new Error(`API error: ${response.status} ${response.statusText}`);
      }

      const result: T = await response.json();
      setData(result);
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return;
      setError(err instanceof Error ? err : new Error(String(err)));
    } finally {
      setIsLoading(false);
    }
  }, [endpoint, options]);

  useEffect(() => {
    fetchData();
    return () => abortRef.current?.abort();
  }, [fetchData]);

  return { data, error, isLoading, refetch: fetchData };
}

This hook handles abort cleanup, loading states, error typing, and refetch capability. In House Escort's dashboard, variations of this pattern power every data-fetching component — property listings, transaction histories, user management — with consistent error handling and loading behavior.

Error Boundaries That Prevent Cascading Failures

A single unhandled error in a React component tree unmounts the entire application. Error boundaries prevent this by catching errors in child components and rendering fallback UI instead of a white screen.

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

interface ErrorBoundaryProps {
  fallback: React.ComponentType<{ error: Error; reset: () => void }>;
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
  children: React.ReactNode;
}

class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    this.props.onError?.(error, errorInfo);
  }

  reset = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError && this.state.error) {
      const Fallback = this.props.fallback;
      return <Fallback error={this.state.error} reset={this.reset} />;
    }
    return this.props.children;
  }
}

We place error boundaries strategically — around each major dashboard section, around data visualization components, and around third-party integrations. If the transaction history component crashes, the property search and user profile sections continue to function.

<ErrorBoundary
  fallback={SectionErrorFallback}
  onError={(error) => captureException(error)}
>
  <TransactionHistory propertyId={id} />
</ErrorBoundary>

React TypeScript Performance Optimization

Performance optimization in React starts with measurement, not memoization. We profile first, then apply targeted optimizations where they matter.

Strategic Memoization

const PropertyCard = memo(function PropertyCard({ property }: PropertyCardProps) {
  return (
    <article className="property-card">
      <img src={property.imageUrl} alt={property.address} loading="lazy" />
      <h3>{property.address}</h3>
      <p>{formatPrice(property.price)}</p>
    </article>
  );
});

memo is applied to components that render frequently with the same props — list items, cards in a grid, table rows. We do not wrap every component in memo. The comparison overhead is wasted on components that always receive new props or render infrequently.

Code Splitting with Lazy and Suspense

const AdminDashboard = lazy(() => import('./views/AdminDashboard'));
const Analytics = lazy(() => import('./views/Analytics'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<AdminDashboard />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

Route-level code splitting in House Escort reduced the initial bundle by 40%. Admin-only views, analytics dashboards, and reporting tools are loaded on demand. The PageSkeleton fallback provides immediate visual feedback while the chunk loads.

Virtualization for Large Lists

When House Escort displays hundreds of property listings, rendering all of them to the DOM is not an option. We virtualize long lists so only visible items are in the DOM at any time, keeping scroll performance smooth regardless of list size.

For more on how these patterns integrate with accessibility requirements, see our article on building 508-compliant government websites. For deployment patterns in regulated environments, see Kubernetes in regulated environments.

Frequently Asked Questions

Why use strict TypeScript instead of the default configuration?

Default TypeScript catches obvious type mismatches but allows patterns that cause runtime errors — unchecked array indexing, implicit any in complex generics, and ambiguous optional properties. Strict mode with noUncheckedIndexedAccess has eliminated entire categories of bugs in our production applications. The upfront cost of satisfying the type checker is repaid many times over in bugs that never reach production.

When should you use memo versus when is it unnecessary?

Use memo on components that render frequently with stable props — list items, table rows, and cards in a repeating layout. Skip it on components that always receive new object or function props (unless you also memoize those with useMemo and useCallback), components that render infrequently, or components that are cheap to render. Profile with React DevTools before and after to confirm the optimization helps.

How do you test custom hooks in isolation?

We use @testing-library/react with its renderHook utility. Custom hooks are tested by rendering them inside a test component and asserting on their return values and state transitions. Network-dependent hooks use MSW (Mock Service Worker) to intercept fetch calls with predictable responses.

What is the performance impact of error boundaries?

Error boundaries add negligible performance overhead during normal operation. They only activate when an error is thrown. The real cost of not using error boundaries is a crashed application — a single uncaught error in any component unmounts the entire React tree. Strategic boundary placement eliminates this risk.

How do you handle state management in large React applications?

We favor component-local state for UI concerns, React Context for shared state that does not change frequently (theme, auth, locale), and server state libraries for API data. Global state stores are used sparingly and only for truly global concerns. This approach keeps the state model predictable and avoids the re-render cascades that monolithic state stores cause.

Discuss your project with Rutagon

Contact Us →

Ready to discuss your project?

We deliver production-grade software for government, defense, and commercial clients. Let's talk about what you need.

Initiate Contact