React
|stacknotice.com
14 min left|
0%
|2,800 words
React

React Design Patterns Senior Devs Actually Use (2026)

Compound components, custom hooks, polymorphic components, provider pattern, state reducer — the React patterns that solve real architecture problems in production apps.

C
Carlos Oliva
Software Developer
July 15, 202614 min read
Share:
React Design Patterns Senior Devs Actually Use (2026)

Most React pattern guides show you the concept with a counter example and call it a day. This guide skips the toy examples and shows the patterns you reach for in real apps: the accordion that the whole team can extend without changing the core component, the form library that doesn't couple validation to the UI, the polymorphic button that renders as <a> or <button> while staying type-safe.

These are the six patterns that actually show up in production codebases.

1. Compound Components

The most important pattern for building flexible, composable UI components. Compound components share implicit state between a parent and a set of child components — the parent manages state, children consume it through context.

The canonical example is every accessible component library: Radix, Headless UI, shadcn/ui. Their <Select>, <Dialog>, and <Accordion> all use compound components internally.

The problem it solves: prop drilling and prop explosion. Instead of:

// ❌ Prop explosion — the parent knows too much
<Tabs
  items={tabs}
  activeTab={activeTab}
  onTabChange={setActiveTab}
  tabClassName="..."
  panelClassName="..."
  indicatorClassName="..."
/>

You get:

// ✅ Compound — each piece is independently controllable
<Tabs defaultValue="account">
  <Tabs.List>
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="security">Security</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account"><AccountSettings /></Tabs.Content>
  <Tabs.Content value="security"><SecuritySettings /></Tabs.Content>
</Tabs>

Implementation:

import { createContext, useContext, useState, ReactNode } from 'react'
 
// 1. Define the shared state type
type TabsContextValue = {
  activeValue: string
  onChange: (value: string) => void
}
 
// 2. Create context
const TabsContext = createContext<TabsContextValue | null>(null)
 
function useTabs() {
  const ctx = useContext(TabsContext)
  if (!ctx) throw new Error('Tabs compound components must be used within <Tabs>')
  return ctx
}
 
// 3. Parent manages state, provides context
type TabsProps = {
  defaultValue: string
  value?: string
  onValueChange?: (value: string) => void
  children: ReactNode
}
 
function Tabs({ defaultValue, value, onValueChange, children }: TabsProps) {
  const [internalValue, setInternalValue] = useState(defaultValue)
  const activeValue = value ?? internalValue
 
  const onChange = (newValue: string) => {
    setInternalValue(newValue)
    onValueChange?.(newValue)
  }
 
  return (
    <TabsContext.Provider value={{ activeValue, onChange }}>
      <div className="w-full">{children}</div>
    </TabsContext.Provider>
  )
}
 
// 4. Children consume context
function TabsList({ children }: { children: ReactNode }) {
  return (
    <div role="tablist" className="flex border-b border-border">
      {children}
    </div>
  )
}
 
function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
  const { activeValue, onChange } = useTabs()
  const isActive = activeValue === value
 
  return (
    <button
      role="tab"
      aria-selected={isActive}
      onClick={() => onChange(value)}
      className={cn(
        'px-4 py-2 text-sm font-medium transition-colors',
        isActive
          ? 'border-b-2 border-primary text-foreground'
          : 'text-muted-foreground hover:text-foreground'
      )}
    >
      {children}
    </button>
  )
}
 
function TabsContent({ value, children }: { value: string; children: ReactNode }) {
  const { activeValue } = useTabs()
  if (activeValue !== value) return null
  return <div role="tabpanel" className="py-4">{children}</div>
}
 
// 5. Attach sub-components as static properties
Tabs.List = TabsList
Tabs.Trigger = TabsTrigger
Tabs.Content = TabsContent
 
export { Tabs }

The useTabs() guard — throwing when used outside the parent — catches misuse at runtime and gives a clear error message.

2. Custom Hooks as the Real Abstraction Layer

The shift that separates junior from senior React code: business logic lives in custom hooks, components are thin renderers.

The bad version: logic scattered in the component

// ❌ Component doing too much
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [isEditing, setIsEditing] = useState(false)
  const [form, setForm] = useState({ name: '', email: '' })
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => { setUser(data); setLoading(false) })
      .catch(e => { setError(e.message); setLoading(false) })
  }, [userId])
 
  async function handleSave() {
    await fetch(`/api/users/${userId}`, { method: 'PATCH', body: JSON.stringify(form) })
    setIsEditing(false)
  }
 
  // ... 80 more lines
}

The pattern: extract everything into a custom hook

// ✅ Hook owns the logic
function useUserProfile(userId: string) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })
 
  const [isEditing, setIsEditing] = useState(false)
  const form = useForm<UserFormValues>({
    resolver: zodResolver(userSchema),
    values: user ? { name: user.name ?? '', email: user.email } : undefined,
  })
 
  const updateUser = useMutation({
    mutationFn: (data: UserFormValues) => patchUser(userId, data),
    onSuccess: (updated) => {
      queryClient.setQueryData(['user', userId], updated)
      setIsEditing(false)
      toast.success('Profile updated')
    },
  })
 
  return {
    user,
    isLoading,
    error,
    isEditing,
    form,
    startEditing: () => setIsEditing(true),
    cancelEditing: () => { setIsEditing(false); form.reset() },
    saveUser: form.handleSubmit((data) => updateUser.mutate(data)),
    isSaving: updateUser.isPending,
  }
}
 
// ✅ Component is a thin renderer
function UserProfile({ userId }: { userId: string }) {
  const {
    user, isLoading, isEditing, form,
    startEditing, cancelEditing, saveUser, isSaving,
  } = useUserProfile(userId)
 
  if (isLoading) return <UserProfileSkeleton />
  if (!user) return <NotFound />
 
  return isEditing
    ? <UserEditForm form={form} onSave={saveUser} onCancel={cancelEditing} isSaving={isSaving} />
    : <UserDisplay user={user} onEdit={startEditing} />
}

The hook is independently testable. The component becomes straightforward enough that anyone on the team can modify it without understanding the data fetching logic.

3. Polymorphic Components

A button that needs to render as <a> on some pages and <button> on others. A card that's sometimes a <div>, sometimes a <Link>. The polymorphic pattern handles this while keeping full TypeScript inference:

import { ElementType, ComponentPropsWithoutRef, ReactNode } from 'react'
 
// The magic: As extends ElementType (any HTML element or React component)
type PolymorphicProps<As extends ElementType> = {
  as?: As
  children?: ReactNode
  className?: string
} & Omit<ComponentPropsWithoutRef<As>, 'as' | 'children' | 'className'>
 
function Button<As extends ElementType = 'button'>({
  as,
  className,
  children,
  ...rest
}: PolymorphicProps<As>) {
  const Component = as ?? 'button'
 
  return (
    <Component
      className={cn(
        'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium',
        'bg-primary text-primary-foreground hover:bg-primary/90',
        'disabled:pointer-events-none disabled:opacity-50',
        className
      )}
      {...rest}
    >
      {children}
    </Component>
  )
}

Usage — TypeScript enforces the correct props for each element:

// Renders as <button> — TypeScript knows onClick, type, disabled, etc.
<Button onClick={handleSubmit}>Submit</Button>
 
// Renders as <a> — TypeScript knows href, target, rel, etc.
<Button as="a" href="/dashboard">Go to dashboard</Button>
 
// Renders as Next.js Link — TypeScript knows href is required
<Button as={Link} href="/blog">Read the blog</Button>
 
// TypeScript error: 'href' is not valid on <button>
<Button href="/wrong">This breaks</Button>

4. Provider Pattern with a Custom Hook Gate

Context is commonly misused: providers scattered across the tree, consumers calling useContext directly without checking for null, or context being used for state that changes too frequently (causing unnecessary re-renders).

The provider pattern done right:

// 1. Typed context with explicit null
type AuthContextValue = {
  user: User | null
  isLoading: boolean
  signOut: () => Promise<void>
}
 
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
 
// 2. Custom hook that guards against usage outside the provider
export function useAuth() {
  const ctx = useContext(AuthContext)
  if (ctx === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return ctx
}
 
// 3. Provider handles all the logic
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)
 
  useEffect(() => {
    getSession().then((session) => {
      setUser(session?.user ?? null)
      setIsLoading(false)
    })
  }, [])
 
  async function signOut() {
    await clearSession()
    setUser(null)
  }
 
  return (
    <AuthContext.Provider value={{ user, isLoading, signOut }}>
      {children}
    </AuthContext.Provider>
  )
}

Split contexts to avoid unnecessary re-renders: if user changes rarely but isLoading changes often, separate them:

const AuthUserContext = createContext<User | null>(null)
const AuthActionsContext = createContext<{ signOut: () => void } | null>(null)
 
// Components that only need the actions don't re-render when user changes
export const useAuthUser = () => useContext(AuthUserContext)
export const useAuthActions = () => {
  const ctx = useContext(AuthActionsContext)
  if (!ctx) throw new Error('useAuthActions must be inside AuthProvider')
  return ctx
}

5. State Reducer Pattern

The state reducer pattern lets consumers override internal state transitions without the component library having to anticipate every use case. It's how Downshift and React Query expose customization.

Use case: a multi-select dropdown that normally closes when an item is selected, but for this specific usage should stay open:

type MultiSelectState = {
  isOpen: boolean
  selectedItems: string[]
  searchValue: string
}
 
type MultiSelectAction =
  | { type: 'toggle_item'; item: string }
  | { type: 'open' }
  | { type: 'close' }
  | { type: 'search'; value: string }
 
function defaultReducer(state: MultiSelectState, action: MultiSelectAction): MultiSelectState {
  switch (action.type) {
    case 'toggle_item': {
      const exists = state.selectedItems.includes(action.item)
      return {
        ...state,
        selectedItems: exists
          ? state.selectedItems.filter((i) => i !== action.item)
          : [...state.selectedItems, action.item],
        isOpen: false,  // default: close on select
      }
    }
    case 'open': return { ...state, isOpen: true }
    case 'close': return { ...state, isOpen: false, searchValue: '' }
    case 'search': return { ...state, searchValue: action.value }
    default: return state
  }
}
 
type MultiSelectProps = {
  items: string[]
  onChange?: (selected: string[]) => void
  // The escape hatch: override any state transition
  stateReducer?: (state: MultiSelectState, action: MultiSelectAction) => MultiSelectState
}
 
function MultiSelect({ items, onChange, stateReducer }: MultiSelectProps) {
  const [state, dispatch] = useReducer(
    (state: MultiSelectState, action: MultiSelectAction) => {
      const newState = defaultReducer(state, action)
      // Allow consumer to override the final state
      return stateReducer ? stateReducer(newState, action) : newState
    },
    { isOpen: false, selectedItems: [], searchValue: '' }
  )
 
  // ... render
}

Consumer overrides only what they need:

// Stay open after selecting an item
<MultiSelect
  items={options}
  stateReducer={(newState, action) => {
    if (action.type === 'toggle_item') {
      return { ...newState, isOpen: true }  // override: keep open
    }
    return newState
  }}
/>

6. Render Props (Modern Version)

Render props fell out of fashion when hooks arrived, but the pattern is still valid for cases where a component needs to expose its internal state to its children with different rendering in each context:

type DataTableRenderProps<T> = {
  rows: T[]
  isLoading: boolean
  selectedRows: T[]
  sortConfig: SortConfig | null
  onSort: (key: keyof T) => void
  onRowSelect: (row: T) => void
}
 
type DataTableProps<T> = {
  data: T[]
  fetchData: (params: FetchParams) => Promise<{ rows: T[]; total: number }>
  children: (props: DataTableRenderProps<T>) => ReactNode
  // or: render?: (props: DataTableRenderProps<T>) => ReactNode
}
 
function DataTable<T>({ data, fetchData, children }: DataTableProps<T>) {
  const [rows, setRows] = useState(data)
  const [isLoading, setIsLoading] = useState(false)
  const [selectedRows, setSelectedRows] = useState<T[]>([])
  const [sortConfig, setSortConfig] = useState<SortConfig | null>(null)
 
  // ... sort and selection logic
 
  return <>{children({ rows, isLoading, selectedRows, sortConfig, onSort, onRowSelect })}</>
}
 
// Usage — caller controls the rendering
<DataTable data={users} fetchData={fetchUsers}>
  {({ rows, isLoading, selectedRows, onSort }) => (
    <div>
      {selectedRows.length > 0 && (
        <BulkActions selected={selectedRows} />
      )}
      <table>
        <thead>
          <tr>
            <SortableHeader onClick={() => onSort('name')}>Name</SortableHeader>
            <SortableHeader onClick={() => onSort('email')}>Email</SortableHeader>
          </tr>
        </thead>
        <tbody>
          {isLoading
            ? <TableSkeleton />
            : rows.map(row => <UserRow key={row.id} user={row} />)
          }
        </tbody>
      </table>
    </div>
  )}
</DataTable>

The render prop approach makes sense when a hook isn't enough — specifically when the component manages DOM interactions that need to be tied to specific elements in the consumer's markup.

Which Pattern for Which Problem

ProblemPattern
Complex component with multiple related sub-partsCompound Components
Business logic reuse across componentsCustom Hook
Component that renders different HTML elementsPolymorphic Component
Global state with selective re-rendersProvider + split contexts
Library that exposes flexible state overrideState Reducer
Component that controls logic but not markupRender Props

These patterns aren't mutually exclusive. A compound component might use the state reducer internally to let consumers customize state transitions. A custom hook might use the provider pattern underneath. Real codebases mix them based on what the problem actually requires — not based on which pattern sounds most sophisticated. Start with the simplest solution, reach for these when the simpler version breaks down.

#react#typescript#nextjs#architecture#patterns
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.