Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsFacade PatternCustom HooksArchitecture

Facade Pattern in React

Master the React Facade pattern — hide complex hooks behind feature facades, Profile/Cart/Notification examples, vs normal hooks, hotel reception analogy, and lean presentational components.

Jul 5, 202616 min read

Introduction

The Facade pattern hides a complex subsystem behind a simple, intention-based API. In React, facades usually appear as custom hooks or lib/ service functions that compose multiple internal hooks, services, and business rules — components call useProfile() or checkoutCart() without knowing about Stripe, TanStack Query, or Redux slices underneath.

This guide covers:

  1. Messy component problems — hook sprawl, mixed logic and JSX
  2. Concept & definition — hotel reception analogy, intention-based API
  3. Implementation — compose hooks, normalize async state, expose UI-ready data
  4. Facade vs normal hooks — one concern vs complete use case
  5. Refactoring benefits — lean, presentational components
  6. Examples — Profile, Shopping Cart, Notification facades
  7. Best practices & pitfalls — when not to facade, testing, file placement

Note

A facade simplifies the interface — it does not delete complexity. Orchestration moves to one module; components stay dumb. See Container-Presenter Pattern for the related split between smart logic and presentational UI.

Quick index

#Section
1Problem: messy components
2Concept & definition
3Implementation in React
4Facade vs normal hooks
5Refactoring benefits
6Example: Profile facade
7Example: Shopping cart facade
8Example: Notification facade
9Best practices & pitfalls

1. Problem: messy components

Without a facade, feature components accumulate imports, hooks, and business rules until they are hard to test and refactor.

SymptomWhat breaks
Too many imported hooksuseQuery, useMutation, useAuth, useDispatch in one file
Mixed business logic and JSXTax math beside <button> markup
Tight couplingComponent knows Stripe, GraphQL, and Redux shapes
Hard to test and refactorMust mount full tree to test a label
Prone to bugsDuplicate discount logic across pages

Before — everything in the component

tsx
'use client'
 
import { useMutation, useQuery } from '@tanstack/react-query'
import { useAuth } from '@/hooks/useAuth'
import { calculateTax } from '@/lib/tax'
import { applyCoupon } from '@/lib/coupons'
 
function CheckoutPage({ cartId }: { cartId: string }) {
  const { user } = useAuth()
  const cartQuery = useQuery({ queryKey: ['cart', cartId], queryFn: () => fetchCart(cartId) })
  const checkoutMutation = useMutation({ mutationFn: (payload) => startCheckout(payload) })
 
  const cart = cartQuery.data
  const subtotal = cart?.items.reduce((s, i) => s + i.price * i.qty, 0) ?? 0
  const discount = cart?.coupon ? applyCoupon(subtotal, cart.coupon) : 0
  const tax = calculateTax(subtotal - discount, user?.address?.state)
  const total = subtotal - discount + tax
 
  if (cartQuery.isLoading) return <p>Loading…</p>
  if (cartQuery.isError) return <p>Error loading cart</p>
 
  return (
    <div>
      <p>Subtotal: ${subtotal}</p>
      <p>Tax: ${tax}</p>
      <p>Total: ${total}</p>
      <button
        type="button"
        disabled={checkoutMutation.isPending}
        onClick={() => checkoutMutation.mutate({ cartId, userId: user!.id, total })}>
        Pay now
      </button>
    </div>
  )
}

Warning

When a component imports more than three domain hooks or computes business totals inline, extract a facade — the JSX file should read like a screen spec, not a spreadsheet.

Back to index


2. Concept & definition

Hotel reception analogy

RoleFacade mapping
Customer (component)Wants a outcome — "check in", "checkout"
Reception (facade)Single desk coordinating departments
Departments (subsystems)Housekeeping, kitchen, billing — hidden internals
Simple interfaceCustomer never visits the kitchen
Complexity hidden, not removedLogic lives behind the desk — not deleted
plaintext
Component  →  useCheckout(cartId)  →  [cart API + tax + Stripe + analytics]
                ↑ simple call              ↑ complex orchestration (hidden)

Facade definition

PropertyMeaning
Simple interfaceOne function or hook for a complete use case
Intention-based APIcompleteProfile(), checkout() — not fetchUser() + patchUser()
Feature-based abstractionGrouped around product features, not tech layers

Note

Facades belong in hooks/useProfile.ts, lib/checkout/facade.ts, or features/cart/useCart.ts — not scattered across JSX. The component asks what; the facade decides how.

Tip

Name facades after user intentions — useProfileCompletion, useCheckout, useNotifications — not after underlying libraries (useQueryCart).

Back to index


3. Implementation in React

Build facades as custom hooks that compose internal concerns and return UI-ready data.

StepAction
Custom hook structureExport useFeature() as the public API
Compose internal hooksCall useQuery, useAuth, useReducer inside
Handle business rulesDiscounts, completion %, grouping — inside hook
Normalize async statesMerge loading/error into one status if helpful
Expose clean UI-ready dataReturn { total, pay, isPaying } — not raw API

Facade hook skeleton

tsx
'use client'
 
import { useMutation, useQuery } from '@tanstack/react-query'
import { useAuth } from '@/hooks/useAuth'
import { calculateTax } from '@/lib/tax'
import { applyCoupon } from '@/lib/coupons'
import { fetchCart, startCheckout } from '@/lib/cart'
 
type CheckoutStatus = 'idle' | 'loading' | 'ready' | 'paying' | 'error'
 
export function useCheckout(cartId: string) {
  const { user } = useAuth()
 
  const cartQuery = useQuery({
    queryKey: ['cart', cartId],
    queryFn: () => fetchCart(cartId),
    enabled: Boolean(cartId),
  })
 
  const checkoutMutation = useMutation({
    mutationFn: startCheckout,
  })
 
  const cart = cartQuery.data
  const subtotal = cart?.items.reduce((s, i) => s + i.price * i.qty, 0) ?? 0
  const discount = cart?.coupon ? applyCoupon(subtotal, cart.coupon) : 0
  const tax = calculateTax(subtotal - discount, user?.address?.state ?? '')
  const total = subtotal - discount + tax
 
  let status: CheckoutStatus = 'idle'
  if (cartQuery.isLoading) status = 'loading'
  else if (cartQuery.isError) status = 'error'
  else if (cart) status = checkoutMutation.isPending ? 'paying' : 'ready'
 
  const pay = () => {
    if (!user?.id) return
    checkoutMutation.mutate({ cartId, userId: user.id, total })
  }
 
  return {
    status,
    lineItems: cart?.items ?? [],
    subtotal,
    discount,
    tax,
    total,
    pay,
    error: cartQuery.error ?? checkoutMutation.error,
  }
}

Lean consumer component

tsx
'use client'
 
function CheckoutPage({ cartId }: { cartId: string }) {
  const { status, subtotal, tax, total, pay, error } = useCheckout(cartId)
 
  if (status === 'loading') return <p>Loading cart…</p>
  if (status === 'error') return <p>{error instanceof Error ? error.message : 'Checkout error'}</p>
 
  return (
    <div>
      <p>Subtotal: ${subtotal}</p>
      <p>Tax: ${tax}</p>
      <p className="font-bold">Total: ${total}</p>
      <button type="button" onClick={pay} disabled={status === 'paying'}>
        {status === 'paying' ? 'Processing…' : 'Pay now'}
      </button>
    </div>
  )
}

Performance

Normalize async state once in the facade — components avoid chaining isLoading || isFetching || isPending in every screen.

Back to index


4. Facade vs normal hooks

Not every custom hook is a facade — the distinction is scope of the use case.

DimensionNormal hookFacade hook
ScopeOne technical concernComplete feature / user journey
ExamplesuseDebounce, useMediaQueryuseCheckout, useProfileCompletion
CompositionUsually standaloneComposes multiple hooks and services
Return shapeRaw or thin wrapperUI-ready, intention-based
ConsumerAny component needing that concernFeature screen or widget
tsx
// Normal hook — single concern
function useDebounce<T>(value: T, delay: number): T {
  /* … */
}
 
// Normal hook — thin data access
function useCartQuery(cartId: string) {
  return useQuery({ queryKey: ['cart', cartId], queryFn: () => fetchCart(cartId) })
}
 
// Facade hook — orchestrates cart + auth + tax + checkout mutation
function useCheckout(cartId: string) {
  const { user } = useAuth()
  const cartQuery = useCartQuery(cartId)
  // business rules + normalized status + pay()
}

Interview Answer

"Facade hook vs Container component?"

A facade hook extracts orchestration into a testable hook — the component can stay a function component with no "container" file. A container component wraps the same logic in a parent that passes props to a presenter. Facades are the modern default; containers remain the mental model when the presenter must be framework-agnostic (Storybook, native mobile sharing types).

Note

Facades often call normal hooks internally — useCheckout uses useAuth and useQuery. Do not facade a one-liner; facade when composition crosses feature boundaries.

Back to index


5. Refactoring benefits

BenefitOutcome
Lean componentsJSX files shrink to layout and copy
Presentational / dumb UISame props whether data comes from REST or GraphQL
Centralized logicFix tax bug once in useCheckout
Improved readabilityScreen reads top-to-bottom like a product spec
Easier testingTest facade hook with @testing-library/react hooks utils
Safer refactorsSwap Stripe for Adyen inside facade — UI unchanged

Refactoring steps

plaintext
1. Identify messy component — hook sprawl + business math in JSX
2. List user intentions — pay, save profile, mark all read
3. Create useFeature() facade — move hooks + rules inside
4. Normalize return shape — status, actions, display values
5. Slim component — render from facade return only
6. Unit test facade — mock sub-hooks or MSW at facade boundary

Tip

Extract facades when the second screen needs the same orchestration — not preemptively on the first screen unless complexity is already high.

Back to index


6. Example: Profile facade

Handles user details, completion percentage, and account status — UI never touches raw API shapes.

tsx
'use client'
 
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchProfile, updateProfile, type Profile } from '@/lib/profile'
 
type AccountStatus = 'incomplete' | 'pending_verification' | 'active' | 'suspended'
 
function computeCompletion(profile: Profile): number {
  const fields = [profile.name, profile.email, profile.avatarUrl, profile.bio, profile.phone]
  const filled = fields.filter(Boolean).length
  return Math.round((filled / fields.length) * 100)
}
 
function deriveAccountStatus(profile: Profile): AccountStatus {
  if (profile.suspended) return 'suspended'
  if (!profile.emailVerified) return 'pending_verification'
  if (computeCompletion(profile) < 100) return 'incomplete'
  return 'active'
}
 
export function useProfile(userId: string) {
  const queryClient = useQueryClient()
 
  const profileQuery = useQuery({
    queryKey: ['profile', userId],
    queryFn: () => fetchProfile(userId),
  })
 
  const saveMutation = useMutation({
    mutationFn: updateProfile,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profile', userId] }),
  })
 
  const profile = profileQuery.data
 
  return {
    status: profileQuery.isLoading ? 'loading' : profileQuery.isError ? 'error' : 'ready',
    displayName: profile?.name ?? 'Guest',
    avatarUrl: profile?.avatarUrl ?? '/default-avatar.png',
    completionPercent: profile ? computeCompletion(profile) : 0,
    accountStatus: profile ? deriveAccountStatus(profile) : 'incomplete',
    isVerified: profile?.emailVerified ?? false,
    save: (patch: Partial<Profile>) => saveMutation.mutate({ userId, ...patch }),
    isSaving: saveMutation.isPending,
    error: profileQuery.error ?? saveMutation.error,
  }
}
tsx
function ProfileHeader({ userId }: { userId: string }) {
  const { displayName, avatarUrl, completionPercent, accountStatus } = useProfile(userId)
 
  return (
    <header className="flex items-center gap-4">
      <img src={avatarUrl} alt="" className="h-12 w-12 rounded-full" />
      <div>
        <h1 className="font-bold">{displayName}</h1>
        <p className="text-sm text-neutral-500">
          Profile {completionPercent}% complete · {accountStatus.replace('_', ' ')}
        </p>
      </div>
    </header>
  )
}

Note

Completion percentage and account status are derived in the facade — not stored in component state or synced via useEffect. See Performance Guide for derived state best practices.

Back to index


7. Example: Shopping cart facade

Encapsulates price calculation, discount logic, and the checkout method.

tsx
'use client'
 
import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchCart, removeCartItem, applyPromoCode, checkoutCart, type Cart } from '@/lib/cart'
 
const PROMO_RATES: Record<string, number> = {
  SAVE10: 0.1,
  SAVE20: 0.2,
}
 
function calculateTotals(cart: Cart) {
  const subtotal = cart.items.reduce((sum, item) => sum + item.price * item.qty, 0)
  const rate = cart.promoCode ? (PROMO_RATES[cart.promoCode] ?? 0) : 0
  const discount = subtotal * rate
  const shipping = subtotal > 50 ? 0 : 5.99
  const total = subtotal - discount + shipping
 
  return { subtotal, discount, shipping, total, itemCount: cart.items.reduce((n, i) => n + i.qty, 0) }
}
 
export function useShoppingCart(cartId: string) {
  const queryClient = useQueryClient()
 
  const cartQuery = useQuery({
    queryKey: ['cart', cartId],
    queryFn: () => fetchCart(cartId),
  })
 
  const removeMutation = useMutation({
    mutationFn: removeCartItem,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cart', cartId] }),
  })
 
  const promoMutation = useMutation({
    mutationFn: applyPromoCode,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cart', cartId] }),
  })
 
  const checkoutMutation = useMutation({
    mutationFn: checkoutCart,
  })
 
  const totals = useMemo(() => (cartQuery.data ? calculateTotals(cartQuery.data) : null), [cartQuery.data])
 
  return {
    isLoading: cartQuery.isLoading,
    items: cartQuery.data?.items ?? [],
    promoCode: cartQuery.data?.promoCode ?? null,
    ...totals,
    removeItem: (productId: string) => removeMutation.mutate({ cartId, productId }),
    applyPromo: (code: string) => promoMutation.mutate({ cartId, code }),
    checkout: () => checkoutMutation.mutate({ cartId }),
    isCheckingOut: checkoutMutation.isPending,
  }
}
tsx
function CartSummary({ cartId }: { cartId: string }) {
  const { items, subtotal, discount, shipping, total, itemCount, applyPromo, checkout, isCheckingOut } =
    useShoppingCart(cartId)
 
  return (
    <aside className="rounded border p-4">
      <h2 className="font-bold">Cart ({itemCount} items)</h2>
      <ul className="my-2 text-sm">
        {items.map((i) => (
          <li key={i.productId}>
            {i.name} × {i.qty}
          </li>
        ))}
      </ul>
      <p>Subtotal: ${subtotal?.toFixed(2)}</p>
      {discount ? <p className="text-green-600">Discount: −${discount.toFixed(2)}</p> : null}
      <p>Shipping: ${shipping?.toFixed(2)}</p>
      <p className="font-bold">Total: ${total?.toFixed(2)}</p>
      <button type="button" className="mr-2" onClick={() => applyPromo('SAVE10')}>
        Apply SAVE10
      </button>
      <button type="button" onClick={checkout} disabled={isCheckingOut}>
        Checkout
      </button>
    </aside>
  )
}

Tip

Keep promo rate tables and shipping rules inside the facade module — not duplicated in mini-cart, cart page, and checkout summary.

Back to index


8. Example: Notification facade

Fetches and groups notifications, tracks unread count, and hides raw API arrays from UI.

tsx
'use client'
 
import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchNotifications, markAsRead, markAllRead, type Notification } from '@/lib/notifications'
 
type NotificationGroup = {
  label: 'Today' | 'Yesterday' | 'Earlier'
  items: { id: string; title: string; timeAgo: string; unread: boolean }[]
}
 
function groupNotifications(notifications: Notification[]): NotificationGroup[] {
  const buckets: Record<string, NotificationGroup['items']> = {
    Today: [],
    Yesterday: [],
    Earlier: [],
  }
 
  for (const n of notifications) {
    const bucket = n.group as NotificationGroup['label']
    buckets[bucket].push({
      id: n.id,
      title: n.title,
      timeAgo: n.timeAgo,
      unread: !n.readAt,
    })
  }
 
  return (['Today', 'Yesterday', 'Earlier'] as const)
    .map((label) => ({ label, items: buckets[label] }))
    .filter((g) => g.items.length > 0)
}
 
export function useNotifications(userId: string) {
  const queryClient = useQueryClient()
 
  const query = useQuery({
    queryKey: ['notifications', userId],
    queryFn: () => fetchNotifications(userId),
    refetchInterval: 30_000,
  })
 
  const readMutation = useMutation({
    mutationFn: markAsRead,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications', userId] }),
  })
 
  const readAllMutation = useMutation({
    mutationFn: markAllRead,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications', userId] }),
  })
 
  const raw = query.data ?? []
 
  const groups = useMemo(() => groupNotifications(raw), [raw])
  const unreadCount = useMemo(() => raw.filter((n) => !n.readAt).length, [raw])
 
  return {
    status: query.isLoading ? 'loading' : query.isError ? 'error' : 'ready',
    groups,
    unreadCount,
    hasUnread: unreadCount > 0,
    markRead: (id: string) => readMutation.mutate({ userId, id }),
    markAllRead: () => readAllMutation.mutate({ userId }),
    // Raw array intentionally NOT exposed
  }
}
tsx
function NotificationBell({ userId }: { userId: string }) {
  const { unreadCount, hasUnread, groups, markRead, markAllRead } = useNotifications(userId)
 
  return (
    <div className="relative">
      <button type="button" aria-label={`Notifications${hasUnread ? `, ${unreadCount} unread` : ''}`}>
        🔔
        {hasUnread && (
          <span className="absolute -top-1 -right-1 rounded-full bg-red-500 px-1 text-xs text-white">
            {unreadCount}
          </span>
        )}
      </button>
      <div className="absolute right-0 mt-2 w-72 rounded border bg-white shadow">
        <div className="flex justify-between border-b px-3 py-2">
          <span className="font-semibold">Notifications</span>
          {hasUnread && (
            <button type="button" className="text-sm text-blue-600" onClick={markAllRead}>
              Mark all read
            </button>
          )}
        </div>
        {groups.map((group) => (
          <section key={group.label} className="px-3 py-2">
            <h3 className="text-xs font-bold text-neutral-400 uppercase">{group.label}</h3>
            <ul>
              {group.items.map((item) => (
                <li key={item.id} className={item.unread ? 'font-semibold' : 'text-neutral-500'}>
                  <button type="button" onClick={() => markRead(item.id)}>
                    {item.title} · {item.timeAgo}
                  </button>
                </li>
              ))}
            </ul>
          </section>
        ))}
      </div>
    </div>
  )
}

Warning

Do not expose raw API arrays from facades when UI needs grouped, filtered, or redacted data — consumers will re-implement grouping logic and drift from the canonical rules.

Back to index


9. Best practices & pitfalls

Practice / pitfallGuidance
Facade per featureuseProfile, useShoppingCart — not one mega-hook
File placementfeatures/*/hooks/ or lib/*/facade.ts
Service facades (non-hook)Server-side checkout orchestration in lib/
Don't facade trivial UIThree-line form — inline state is fine
Test at facade boundaryMock queries once — assert total, pay()
Avoid leaking internalsDon't return raw query objects to JSX

Non-hook facade — server checkout

tsx
// lib/payments/checkoutFacade.ts — wraps Stripe + tax + analytics
export async function checkoutCart(cartId: string, userId: string) {
  const cart = await db.cart.findUnique({ where: { id: cartId } })
  if (!cart) throw new Error('Cart not found')
 
  const tax = await taxService.calculate(cart)
  const intent = await stripe.paymentIntents.create({
    amount: cart.subtotal + tax,
    currency: 'usd',
    metadata: { cartId, userId },
  })
 
  analytics.track('checkout_started', { cartId, userId })
  return { clientSecret: intent.client_secret }
}

Warning

God facades — one useApp() returning everything — recreate global state nightmares. Split by feature boundary; compose at the page level if needed.

Warning

Facades that only re-export useQuery with no business logic add indirection without value. A facade must orchestrate or transform — otherwise use the normal hook directly.

Tip

Production checklist

  • Intention-based hook names (useCheckout, not useCartQuery)
  • Compose hooks + services inside facade
  • Normalize loading/error/status for UI
  • Derive display values in facade — not in JSX
  • Hide raw API shapes from components
  • Unit test facade; snapshot test presentational child
  • Server orchestration in lib/ facades for Server Actions
  • Split before facades become god hooks

Interview Answer

"Facade vs Strategy pattern?"

Facade simplifies access to multiple subsystems for one use case — checkout combines cart, tax, and payment. Strategy swaps one interchangeable algorithm — OTP vs SSO login. A checkout facade might use a payment strategy internally without exposing it to the button component.

Back to index


Summary

LayerResponsibility
ComponentLayout, copy, user events
Facade hookOrchestration, business rules, normalized state
Internal hooksData fetch, auth, mutations
Services / libAPI clients, Stripe, tax, analytics

The Facade pattern gives components a hotel reception desk — one simple request, many departments working behind the scenes. Extract feature facades when hook sprawl and business math overwhelm JSX.

Next reads: Container-Presenter Pattern for smart/dumb splits, Strategy Pattern for swappable algorithms inside facades, and React Design Patterns for the full pattern roadmap.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime