TypeScript With React Patterns
Master advanced React patterns with TypeScript including discriminated unions, polymorphic components, and compound architectures.
TL;DR
- Model mutually exclusive prop configurations using discriminated
typeunion structures. - Build polymorphic component wrappers leveraging
React.ElementTypegeneric constraints. - Share nested compound component state through strictly typed
createContextproviders.
Discriminated Union Props
Variant prop unionModel mutually exclusive component states.
type AlertProps =
| { variant: "ok"; text: string }
| { variant: "err"; text: string; code: number };
function Alert(p: AlertProps) {
return <Card>{p.text}</Card>;
}Discriminant property narrowingAccess variant properties after checking.
function renderAlert(props: AlertProps) {
if (props.variant === "error") {
console.log("Error code:", props.code);
}
}Exhaustive never checkEnsure all union variants are handled.
type Status = "idle" | "busy";
function getLabel(s: Status): string {
switch (s) {
case "idle": return "Wait";
case "busy": return "Work";
default: {
const _: never = s;
return _;
}
}
}Polymorphic Components
Generic as prop helperExtract native props based on ElementType.
import type {
ElementType,
ComponentPropsWithRef
} from "react";
type AsProps<E extends ElementType> = {
as?: E;
} & ComponentPropsWithRef<E>;Polymorphic Box componentDynamically render element with correct props.
import type { ElementType } from "react";
function Box<E extends ElementType = "button">(
{ as, ...rest }: AsProps<E>
) {
const Component = as ?? "button";
return <Component {...rest} />;
}Type safe polymorphic invocationTypeScript narrows valid element attributes.
// Valid: button allows onClick and disabled
const btn = <Box as="button" disabled={true} />;Forward Ref and Handles
Imperative handle interfaceDefine methods callable via component ref.
export interface ModalHandle {
open: () => void;
close: () => void;
}Implementing useImperativeHandleExpose handle methods instead of raw DOM.
import {
forwardRef, useImperativeHandle
} from "react";
const Modal = forwardRef<
ModalHandle, { title: string }
>(({ title }, ref) => {
useImperativeHandle(ref, () => ({
open: () => {}, close: () => {}
}));
return <Card>{title}</Card>;
});Calling imperative handleInvoke exposed handle methods from parent.
import { useRef } from "react";
function Page() {
const ref = useRef<ModalHandle>(null);
return (
<Button onClick={() => ref.current?.open()} />
);
}Compound Components Pattern
Compound component contextShare parent state with child components.
import { createContext } from "react";
interface TabsCtx {
activeId: string;
setActive: (id: string) => void;
}
const Ctx = createContext<TabsCtx | null>(null);Parent container providerWrap children in compound context provider.
import type { ReactNode } from "react";
function Tabs({ children }: { children: ReactNode }) {
const [activeId, setActiveId] = useState("tab1");
return (
<Ctx.Provider value={{ activeId, setActiveId }}>
<Container>{children}</Container>
</Ctx.Provider>
);
}Child component hook consumerConsume parent context inside compound child.
interface TabProps { id: string; label: string; }
function TabItem({ id, label }: TabProps) {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("TabItem must be in Tabs");
return (
<Button onClick={() => ctx.setActiveId(id)}>
{label}
</Button>
);
}Attaching static propertiesGroup subcomponents under parent namespace.
Tabs.Item = TabItem;
// Usage:
// <Tabs><Tabs.Item id="t1" label="Home" /></Tabs>Render Props and Custom Hooks
Render prop patternPass data to render callbacks cleanly.
interface FetcherProps<T> {
url: string;
render: (
data: T,
loading: boolean
) => React.ReactNode;
}
function Fetcher<T>({ url, render }: FetcherProps<T>) {
// data fetching logic here
return null;
}Typed tuple returnMark tuple returns with as const to preserve types.
function useToggle(initial = false) {
const [open, setOpen] = useState(initial);
const toggle = () => setOpen(prev => !prev);
return [open, toggle] as const;
}Generic custom hookInfer hook payload data types using generic parameters.
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
return { data: null, loading: false, error: null };
}Tips
- Utilize an exhaustive
switchblock with aneverfallback check when rendering discriminated union props to ensure missing variants trigger compile errors. - Return custom hook arrays typed with
as constto produce strongly typed tuples rather than broadened array unions.
Warnings
- Avoid using
as anywhen implementing polymorphicascomponents; allow generics to propagate native element attributes safely. - Remember to wrap child elements using
<Fragment>or capitalized components rather than raw lowercase tags to ensure clean JSX syntax separation.
In Practice
A production-grade polymorphic button component combining discriminated styling variants with dynamic HTML tag polymorphism.
- Define discriminated variant types for styling modes.
- Combine variant types with polymorphic ElementType props.
- Forward standard element attributes onto the dynamic component.
- Render with full type safety across buttons and link anchors.
import React from "react";
type ButtonVariant =
| { variant: "solid"; color: string }
| { variant: "outline"; borderColor: string };
type PolymorphicProps<E extends React.ElementType> = {
as?: E;
} & ButtonVariant &
React.ComponentPropsWithoutRef<E>;
export function ActionButton<
E extends React.ElementType = "button"
>({ as, variant, ...rest }: PolymorphicProps<E>) {
const Component = as ?? "button";
return <Component {...rest} />;
}FAQ
Discriminated unions ensure that incompatible props cannot be supplied together. For example, a notification modal can require an errorCode property when variant: 'error' is set, while disallowing errorCode entirely when variant: 'success'. TypeScript enforces this at compile time, eliminating impossible component states.
A polymorphic component accepts an as prop allowing callers to alter the underlying rendered element (e.g. rendering a as an anchor tag or React Router ). By typing the component with a generic parameter E extends React.ElementType, TypeScript dynamically narrows allowable props to match that specific element.
Compound components share state through a typed React Context provider and expose subcomponents as static properties on the parent function. You define an interface representing the parent callable component with named properties for subcomponents (like Table.Row and Table.Cell).
Wrap your component with React.forwardRef and call useImperativeHandle(ref, () => ({ open, close })). Callers attach a ref typed as useRef and can trigger imperative actions without touching internal DOM elements.
Without as const, TypeScript infers an array return type like (string | (() => void))[], losing positional information and type precision. Adding as const narrows the output to a readonly tuple readonly [string, () => void], matching the behavior of useState.