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.
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:
- Messy component problems — hook sprawl, mixed logic and JSX
- Concept & definition — hotel reception analogy, intention-based API
- Implementation — compose hooks, normalize async state, expose UI-ready data
- Facade vs normal hooks — one concern vs complete use case
- Refactoring benefits — lean, presentational components
- Examples — Profile, Shopping Cart, Notification facades
- Best practices & pitfalls — when not to facade, testing, file placement
Quick index
1. Problem: messy components
Without a facade, feature components accumulate imports, hooks, and business rules until they are hard to test and refactor.
| Symptom | What breaks |
|---|---|
| Too many imported hooks | useQuery, useMutation, useAuth, useDispatch in one file |
| Mixed business logic and JSX | Tax math beside <button> markup |
| Tight coupling | Component knows Stripe, GraphQL, and Redux shapes |
| Hard to test and refactor | Must mount full tree to test a label |
| Prone to bugs | Duplicate discount logic across pages |
Before — everything in the component
'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>
)
}2. Concept & definition
Hotel reception analogy
| Role | Facade mapping |
|---|---|
| Customer (component) | Wants a outcome — "check in", "checkout" |
| Reception (facade) | Single desk coordinating departments |
| Departments (subsystems) | Housekeeping, kitchen, billing — hidden internals |
| Simple interface | Customer never visits the kitchen |
| Complexity hidden, not removed | Logic lives behind the desk — not deleted |
Component → useCheckout(cartId) → [cart API + tax + Stripe + analytics]
↑ simple call ↑ complex orchestration (hidden)Facade definition
| Property | Meaning |
|---|---|
| Simple interface | One function or hook for a complete use case |
| Intention-based API | completeProfile(), checkout() — not fetchUser() + patchUser() |
| Feature-based abstraction | Grouped around product features, not tech layers |
3. Implementation in React
Build facades as custom hooks that compose internal concerns and return UI-ready data.
| Step | Action |
|---|---|
| Custom hook structure | Export useFeature() as the public API |
| Compose internal hooks | Call useQuery, useAuth, useReducer inside |
| Handle business rules | Discounts, completion %, grouping — inside hook |
| Normalize async states | Merge loading/error into one status if helpful |
| Expose clean UI-ready data | Return { total, pay, isPaying } — not raw API |
Facade hook skeleton
'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
'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>
)
}4. Facade vs normal hooks
Not every custom hook is a facade — the distinction is scope of the use case.
| Dimension | Normal hook | Facade hook |
|---|---|---|
| Scope | One technical concern | Complete feature / user journey |
| Examples | useDebounce, useMediaQuery | useCheckout, useProfileCompletion |
| Composition | Usually standalone | Composes multiple hooks and services |
| Return shape | Raw or thin wrapper | UI-ready, intention-based |
| Consumer | Any component needing that concern | Feature screen or widget |
// 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()
}5. Refactoring benefits
| Benefit | Outcome |
|---|---|
| Lean components | JSX files shrink to layout and copy |
| Presentational / dumb UI | Same props whether data comes from REST or GraphQL |
| Centralized logic | Fix tax bug once in useCheckout |
| Improved readability | Screen reads top-to-bottom like a product spec |
| Easier testing | Test facade hook with @testing-library/react hooks utils |
| Safer refactors | Swap Stripe for Adyen inside facade — UI unchanged |
Refactoring steps
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 boundary6. Example: Profile facade
Handles user details, completion percentage, and account status — UI never touches raw API shapes.
'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,
}
}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>
)
}7. Example: Shopping cart facade
Encapsulates price calculation, discount logic, and the checkout method.
'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,
}
}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>
)
}8. Example: Notification facade
Fetches and groups notifications, tracks unread count, and hides raw API arrays from UI.
'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
}
}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>
)
}9. Best practices & pitfalls
| Practice / pitfall | Guidance |
|---|---|
| Facade per feature | useProfile, useShoppingCart — not one mega-hook |
| File placement | features/*/hooks/ or lib/*/facade.ts |
| Service facades (non-hook) | Server-side checkout orchestration in lib/ |
| Don't facade trivial UI | Three-line form — inline state is fine |
| Test at facade boundary | Mock queries once — assert total, pay() |
| Avoid leaking internals | Don't return raw query objects to JSX |
Non-hook facade — server checkout
// 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 }
}Summary
| Layer | Responsibility |
|---|---|
| Component | Layout, copy, user events |
| Facade hook | Orchestration, business rules, normalized state |
| Internal hooks | Data fetch, auth, mutations |
| Services / lib | API 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.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime