Build React 19 applications with TypeScript. Covers Actions, Activity, use() hook, React Compiler, ref-as-prop, useEffectEvent, and strict TypeScript patterns. Use when creating components, managing state, typing props, handling events, using hooks, or working with React 19 features. Triggers on react, typescript, tsx, component types, hook types, react 19, react compiler, actions, use hook, useEffectEvent, activity, import defer.
Patterns for building type-safe React 19.2 applications with TypeScript 5.9. React Compiler handles memoization automatically - write plain components, let the tooling optimize.
// WRONG - deprecated pattern
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
))
// CORRECT - React 19: ref is a regular prop
function Input({ ref, ...props }: React.ComponentProps<"input">) {
return <input ref={ref} {...props} />
}
// WRONG - unnecessary with React Compiler
const MemoizedList = memo(function List({ items }: { items: Item[] }) {
const sorted = useMemo(() => items.toSorted(compare), [items])
const handleClick = useCallback((id: string) => onSelect(id), [onSelect])
return sorted.map(item => <Row key={item.id} onClick={() => handleClick(item.id)} />)
})
// CORRECT - React Compiler auto-memoizes all of this
function List({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
const sorted = items.toSorted(compare)
return sorted.map(item => <Row key={item.id} onClick={() => onSelect(item.id)} />)
}
React.ComponentProps<> for Element Props// WRONG - manual HTML attribute typing
interface ButtonProps {
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
disabled?: boolean
children: React.ReactNode
className?: string
}
// CORRECT - extend native element props
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "ghost"
}
// WRONG - impossible states possible
interface RequestState { isLoading: boolean; error: string | null; data: User | null }
// CORRECT - discriminated union prevents impossible states
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: User }
satisfies for Type-Safe Literals// WRONG - widens to Record<string, Route>
const routes: Record<string, Route> = { home: { path: "/" }, about: { path: "/about" } }
// CORRECT - preserves literal keys while checking shape
const routes = {
home: { path: "/" },
about: { path: "/about" },
} satisfies Record<string, Route>
routes.home // typed, autocomplete works
// WRONG - null default with no guard
const AuthContext = createContext<AuthState | null>(null)
// consumers must null-check every time
// CORRECT - factory hook that throws on missing provider
const AuthContext = createContext<AuthState | null>(null)
function useAuth(): AuthState {
const ctx = use(AuthContext)
if (ctx === null) throw new Error("useAuth must be used within AuthProvider")
return ctx
}
use() over useContext()// OLD pattern
function Header() {
const theme = useContext(ThemeContext) // cannot use after early return
if (!isVisible) return null
return <h1 style={{ color: theme.color }}>Title</h1>
}
// CORRECT - React 19: use() works after early returns
function Header({ isVisible }: { isVisible: boolean }) {
if (!isVisible) return null
const theme = use(ThemeContext) // works here - use() is not bound by hook rules
return <h1 style={{ color: theme.color }}>Title</h1>
}
Plain functions with data-slot for styling hooks. No forwardRef, no FC:
type CardProps = React.ComponentProps<"div"> & {
variant?: "elevated" | "outlined"
}
function Card({ variant = "outlined", className, ...props }: CardProps) {
return (
<div
data-slot="card"
data-variant={variant}
className={cn("rounded-xl border bg-card", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return <h3 data-slot="card-title" className={cn("font-semibold", className)} {...props} />
}
Async functions in transitions handle pending state, errors, and form resets automatically:
function UpdateProfile({ userId }: { userId: string }) {
const [error, submitAction, isPending] = useActionState(
async (_prev: string | null, formData: FormData) => {
const result = await updateProfile(userId, formData)
if (result.error) return result.error
redirect("/profile")
return null
},
null
)
return (
<form action={submitAction}>
<input type="text" name="displayName" required />
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{error && <p className="text-destructive">{error}</p>}
</form>
)
}
useTransition for non-form Actions:
function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => startTransition(async () => { await onDelete() })}
>
{isPending ? "Deleting..." : "Delete"}
</button>
)
}
useOptimistic for instant feedback:
function LikeButton({ likes, onLike }: { likes: number; onLike: () => Promise<void> }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (prev) => prev + 1)
const handleLike = async () => {
addOptimisticLike(null)
await onLike()
}
return (
<form action={handleLike}>
<button type="submit">{optimisticLikes} Likes</button>
</form>
)
}
Read promises and context in render. Works conditionally, after early returns:
// Reading a promise - suspends until resolved
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise)
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
)
}
// Parent gets promise from loader/cache, NOT created during render
function PostPage({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
return (
<Suspense fallback={<Skeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
)
}
// Reading context conditionally
function AdminPanel({ user }: { user: User | null }) {
if (!user) return <LoginPrompt />
const permissions = use(PermissionsContext) // legal - use() works after early return
if (!permissions.isAdmin) return <Forbidden />
return <Dashboard user={user} permissions={permissions} />
}
Important: use() does not support promises created during render. Pass promises from loaders, server functions, or cached sources.
Preserve state of hidden UI. Hidden children keep their state and DOM but unmount effects:
import { Activity, useState } from "react"
function TabLayout({ tabs }: { tabs: TabConfig[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id)
return (
<div>
<nav>
{tabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}>
{tab.label}
</button>
))}
</nav>
{tabs.map(tab => (
<Activity key={tab.id} mode={activeTab === tab.id ? "visible" : "hidden"}>
<tab.component />
</Activity>
))}
</div>
)
}
Key behaviors:
visible - renders normally, effects mountedhidden - hides via display: none, effects cleaned up, state preserved, updates deferred<Activity mode="hidden"> renders children at low priority for faster future revealsuseLayoutEffect cleanupExtract non-reactive logic from effects. The event function always sees latest props/state without triggering effect re-runs:
function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme) // always reads latest theme
})
useEffect(() => {
const connection = createConnection(roomId)
connection.on("connected", () => onConnected())
connection.connect()
return () => connection.disconnect()
}, [roomId]) // theme NOT in deps - onConnected is an Effect Event
}
Rules:
Custom hook pattern:
function useInterval(callback: () => void, delay: number | null) {
const onTick = useEffectEvent(callback)
useEffect(() => {
if (delay === null) return
const id = setInterval(() => onTick(), delay)
return () => clearInterval(id)
}, [delay])
}
Render <title>, <meta>, and <link> directly in components - React hoists them to <head>:
function BlogPost({ post }: { post: Post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<meta name="author" content={post.author} />
<link rel="canonical" href={`https://example.com/posts/${post.slug}`} />
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
const ThemeContext = createContext<Theme>("light")
// React 19 - use Context directly as provider (no .Provider)
function App({ children }: { children: React.ReactNode }) {
return (
<ThemeContext value="dark">
{children}
</ThemeContext>
)
}
function MeasuredBox() {
return (
<div
ref={(node) => {
if (node) {
const observer = new ResizeObserver(handleResize)
observer.observe(node)
return () => observer.disconnect() // cleanup on unmount
}
}}
/>
)
}
React Compiler (babel-plugin-react-compiler) analyzes your code at build time and automatically inserts memoization. It replaces manual useMemo, useCallback, and React.memo in most cases.
Auto-memoizes:
pnpm add -D babel-plugin-react-compiler
// vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"], // must be first
},
}),
],
})
// DON'T - compiler handles this
const MemoComponent = memo(MyComponent)
const memoized = useMemo(() => expensive(data), [data])
const stableCallback = useCallback(() => handler(id), [id])
// DO - write plain code, compiler optimizes
function MyComponent({ data, onSelect }: Props) {
const processed = expensive(data)
return <Child onClick={() => onSelect(data.id)} />
}
useMemo/useCallback as effect dependencies when you need precise control over when effects fire"use no memo" directive skips compilation for a specific componentComponents optimized by the compiler show a "Memo" badge in React DevTools. Check build output for react/compiler-runtime imports.
{
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "nodenext",
"moduleDetection": "force",
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUncheckedSideEffectImports": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": []
}
}
// Extending native element props
type ButtonProps = React.ComponentProps<"button"> & {
variant?: "primary" | "secondary"
isLoading?: boolean
}
function Button({ variant = "primary", isLoading, children, ...props }: ButtonProps) {
return (
<button data-slot="button" disabled={isLoading || props.disabled} {...props}>
{isLoading ? <Spinner /> : children}
</button>
)
}
// Polymorphic "as" prop
type PolymorphicProps<E extends React.ElementType> = {
as?: E
} & Omit<React.ComponentProps<E>, "as">
function Text<E extends React.ElementType = "span">({
as,
...props
}: PolymorphicProps<E>) {
const Component = as || "span"
return <Component {...props} />
}
// Usage: <Text as="h1">Hello</Text>
function Form() {
// Inferred from handler context - no explicit typing needed
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
// process formData
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") submit()
}
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
</form>
)
}
// useState - inferred when initial value provided
const [count, setCount] = useState(0) // number
const [user, setUser] = useState<User | null>(null) // explicit for null init
// useReducer - discriminated union actions
type CounterAction =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
function counterReducer(state: number, action: CounterAction): number {
switch (action.type) {
case "increment": return state + action.amount
case "decrement": return state - action.amount
case "reset": return 0
}
}
const [count, dispatch] = useReducer(counterReducer, 0)
dispatch({ type: "increment", amount: 5 })
// useRef - element refs (React 19: returns RefObject<T | null>, always nullable)
const inputRef = useRef<HTMLInputElement>(null)
// useRef - mutable value (no null)
const intervalRef = useRef<number | undefined>(undefined)
type SelectProps<T> = {
items: T[]
value: T
onChange: (item: T) => void
getLabel: (item: T) => string
getKey: (item: T) => string
}
function Select<T>({ items, value, onChange, getLabel, getKey }: SelectProps<T>) {
return (
<select
value={getKey(value)}
onChange={(e) => {
const item = items.find(i => getKey(i) === e.target.value)
if (item) onChange(item)
}}
>
{items.map(item => (
<option key={getKey(item)} value={getKey(item)}>
{getLabel(item)}
</option>
))}
</select>
)
}
// Usage - T inferred as User
<Select
items={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={u => u.name}
getKey={u => u.id}
/>
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T }
function AsyncContent<T>({
state,
render,
}: {
state: AsyncState<T>
render: (data: T) => React.ReactNode
}) {
switch (state.status) {
case "idle": return null
case "loading": return <Spinner />
case "error": return <ErrorDisplay error={state.error} />
case "success": return <>{render(state.data)}</>
}
}
import { z } from "zod"
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "user", "viewer"]),
})
type User = z.infer<typeof UserSchema>
// Form validation with useActionState
type FieldErrors = { name?: string[]; email?: string[]; role?: string[] }
function CreateUser() {
const [errors, submitAction, isPending] = useActionState(
async (_prev: FieldErrors | null, formData: FormData) => {
const result = UserSchema.safeParse(Object.fromEntries(formData))
if (!result.success) {
// Zod v4: use z.flattenError() to get field-level errors
const flat = z.flattenError(result.error)
return flat.fieldErrors as FieldErrors
}
await saveUser(result.data)
return null
},
null
)
return (
<form action={submitAction}>
<input name="name" />
{errors?.name && (
<span className="text-destructive">{errors.name[0]}</span>
)}
<input name="email" type="email" />
<select name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</select>
<button type="submit" disabled={isPending}>Create</button>
</form>
)
}
FC, no forwardRef, no memo. FC implicitly typed children in older types and adds no value over plain function signatures. Let React Compiler optimize.React.ComponentProps<"element"> for extending native elements - catches all HTML attributes.use() over useContext() - works conditionally, cleaner for context with guards.satisfies for config objects - preserves literal types while validating shape.Activity over conditional rendering when state preservation matters (tabs, sidebars, wizards).useEffectEvent over suppressing deps - extracts non-reactive logic cleanly from effects.noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax.as const satisfies for route configs, theme tokens, and lookup objects.never in default cases.import defer (TS 5.9) - defer module evaluation until first property access for lazy-loaded heavy modules. See typescript-patterns.md.