Strategy Pattern & Hook Factory in React
Master the React Strategy pattern — strategy registry, hook factory, Context providers, OTP/SSO/Magic Link auth, REST/GraphQL fetch strategies, and when to avoid if-else sprawl.
Introduction
The Strategy pattern encapsulates interchangeable behaviors in independent units — swap algorithms at runtime without rewriting consumers. In React, strategies appear as plain objects, custom hooks, or hook factories that generate hooks bound to a specific implementation. A strategy registry maps keys to implementations; a selector picks the right one.
This guide covers:
- Scalability problems — why giant
if/elseblocks fail - Pattern fundamentals — registry, selector, abstraction
- Hook factory — React-safe strategy wiring
- Global strategy with Context — Provider-level selection
- Authentication strategies — OTP, Google SSO, Magic Link
- Data fetching strategies — REST, GraphQL, in-memory JSON
- Key benefits — maintainability, testability, clean consumers
- Best practices & pitfalls — Rules of Hooks, over-engineering
Quick index
1. Core scalability problems
Before adopting Strategy, recognize what monolithic conditional logic costs as features grow.
| Problem | Symptom |
|---|---|
| Complex if/else conditions | Every new auth method adds another branch in one hook |
| Untestable hook logic | Cannot test OTP path without mounting SSO side effects |
| Merge conflict headaches | Multiple devs edit the same 400-line useAuth file |
| Hard to extend or maintain | Changing Google SSO breaks Magic Link accidentally |
Before — strategy sprawl in one hook
'use client'
import { useState } from 'react'
type AuthMethod = 'otp' | 'sso' | 'magicLink'
function useAuth(method: AuthMethod) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function login(input: string) {
setLoading(true)
setError(null)
try {
if (method === 'otp') {
await fetch('/api/auth/otp', { method: 'POST', body: JSON.stringify({ phone: input }) })
} else if (method === 'sso') {
window.location.href = `/api/auth/google?redirect=${input}`
} else if (method === 'magicLink') {
await fetch('/api/auth/magic-link', { method: 'POST', body: JSON.stringify({ email: input }) })
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Login failed')
} finally {
setLoading(false)
}
}
return { login, loading, error }
}2. Strategy pattern fundamentals
Four pieces compose every Strategy implementation.
| Piece | Role |
|---|---|
| Independent behavior boxes | Self-contained strategy modules — one file per algorithm |
| Strategy registry | Config object mapping keys → implementations |
| Strategy selector | Function or Provider that picks the active strategy |
| Implementation abstraction | Shared interface consumers depend on — not concrete code |
Consumer
→ calls strategy.login(credentials)
→ selector picked "otp" from registry
→ OtpStrategy.login runs
→ consumer never imports OtpStrategy directlyStrategy interface + registry
type LoginResult = { ok: true; sessionId: string } | { ok: false; error: string }
type AuthStrategy = {
name: string
login: (input: string) => Promise<LoginResult>
logout: () => Promise<void>
}
const otpStrategy: AuthStrategy = {
name: 'otp',
async login(phone) {
const res = await fetch('/api/auth/otp', { method: 'POST', body: JSON.stringify({ phone }) })
if (!res.ok) return { ok: false, error: 'OTP send failed' }
const { sessionId } = await res.json()
return { ok: true, sessionId }
},
async logout() {
await fetch('/api/auth/logout', { method: 'POST' })
},
}
const ssoStrategy: AuthStrategy = {
name: 'sso',
async login(redirectUri) {
window.location.href = `/api/auth/google?redirect=${encodeURIComponent(redirectUri)}`
return { ok: true, sessionId: 'pending' }
},
async logout() {
await fetch('/api/auth/logout', { method: 'POST' })
},
}
// Strategy registry — configuration object
export const authRegistry = {
otp: otpStrategy,
sso: ssoStrategy,
} as const satisfies Record<string, AuthStrategy>
export type AuthStrategyKey = keyof typeof authRegistry
// Strategy selector
export function selectAuthStrategy(key: AuthStrategyKey): AuthStrategy {
return authRegistry[key]
}3. Hook factory pattern
A hook factory returns a custom hook bound to a specific strategy — solves duplication while respecting React's Rules of Hooks.
| Hook factory benefit | Detail |
|---|---|
| Avoids conditional hook calls | Each exported hook is fixed at creation time |
| Individual custom hooks | useOtpAuth, useSsoAuth — isolated, testable |
| Factory returns hook by config | createUsePersistedState(storageStrategy) |
| Isolated units | Unit-test strategy logic without full component |
Factory — persisted state example
'use client'
import { useEffect, useState } from 'react'
type StorageStrategy = {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}
const localStorageStrategy: StorageStrategy = {
getItem: (key) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
}
const sessionStorageStrategy: StorageStrategy = {
getItem: (key) => (typeof window !== 'undefined' ? sessionStorage.getItem(key) : null),
setItem: (key, value) => sessionStorage.setItem(key, value),
removeItem: (key) => sessionStorage.removeItem(key),
}
function createUsePersistedState(strategy: StorageStrategy) {
return function usePersistedState<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = strategy.getItem(key)
return raw ? (JSON.parse(raw) as T) : initial
})
useEffect(() => {
strategy.setItem(key, JSON.stringify(value))
}, [key, value])
const clear = () => {
strategy.removeItem(key)
setValue(initial)
}
return [value, setValue, clear] as const
}
}
export const useLocalStorage = createUsePersistedState(localStorageStrategy)
export const useSessionStorage = createUsePersistedState(sessionStorageStrategy)Per-strategy auth hooks
'use client'
import { useCallback, useState } from 'react'
import { authRegistry, type AuthStrategyKey } from './authRegistry'
function createUseAuthStrategy(key: AuthStrategyKey) {
const strategy = authRegistry[key]
return function useAuthStrategy() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const login = useCallback(
async (input: string) => {
setLoading(true)
setError(null)
const result = await strategy.login(input)
if (!result.ok) setError(result.error)
setLoading(false)
return result
},
[strategy],
)
const logout = useCallback(() => strategy.logout(), [strategy])
return { login, logout, loading, error, strategyName: strategy.name }
}
}
export const useOtpAuth = createUseAuthStrategy('otp')
export const useSsoAuth = createUseAuthStrategy('sso')
// Add more: export const useMagicLinkAuth = createUseAuthStrategy('magicLink')4. Global strategy with Context
When the whole app shares one active strategy, a Provider selects from the registry and exposes uniform access via Context.
| Context strategy piece | Role |
|---|---|
| Context decides location | Strategy selected once at Provider boundary |
| Provider wraps hierarchy | Descendants read the same implementation |
| Strategy name as prop | <AuthProvider strategy="otp"> switches app-wide |
| Uniform access | useAuth() — consumers ignore concrete strategy |
'use client'
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { authRegistry, type AuthStrategy, type AuthStrategyKey } from './authRegistry'
const AuthStrategyContext = createContext<AuthStrategy | null>(null)
type AuthProviderProps = {
strategy: AuthStrategyKey
children: ReactNode
}
export function AuthProvider({ strategy, children }: AuthProviderProps) {
const value = useMemo(() => authRegistry[strategy], [strategy])
return <AuthStrategyContext.Provider value={value}>{children}</AuthStrategyContext.Provider>
}
export function useAuthStrategyContext() {
const strategy = useContext(AuthStrategyContext)
if (!strategy) throw new Error('useAuthStrategyContext requires AuthProvider')
return strategy
}// app/layout.tsx — tenant A uses OTP, tenant B uses SSO
;<AuthProvider strategy="otp">{children}</AuthProvider>
// Deep consumer — clean, no if/else
function LogoutButton() {
const auth = useAuthStrategyContext()
return (
<button type="button" onClick={() => auth.logout()}>
Log out ({auth.name})
</button>
)
}See Provider Pattern for Context boundaries, memoized values, and split-context performance tips.
5. Example: Authentication strategies
Three interchangeable login flows sharing one AuthStrategy interface.
Magic Link strategy
const magicLinkStrategy: AuthStrategy = {
name: 'magicLink',
async login(email) {
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!res.ok) return { ok: false, error: 'Could not send magic link' }
return { ok: true, sessionId: 'email-sent' }
},
async logout() {
await fetch('/api/auth/logout', { method: 'POST' })
},
}
export const authRegistry = {
otp: otpStrategy,
sso: ssoStrategy,
magicLink: magicLinkStrategy,
} as const satisfies Record<string, AuthStrategy>Unified login form — strategy-driven UI
'use client'
import { useState } from 'react'
import { useAuthStrategyContext } from './AuthProvider'
const strategyLabels = {
otp: { label: 'Phone number', placeholder: '+1 555 000 0000', inputType: 'tel' },
sso: { label: 'Redirect URL', placeholder: '/dashboard', inputType: 'url' },
magicLink: { label: 'Email', placeholder: 'you@example.com', inputType: 'email' },
} as const
function LoginForm() {
const auth = useAuthStrategyContext()
const config = strategyLabels[auth.name as keyof typeof strategyLabels]
const [input, setInput] = useState('')
const [message, setMessage] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const result = await auth.login(input)
if (result.ok) {
setMessage(auth.name === 'magicLink' ? 'Check your email for a link.' : 'Success!')
} else {
setMessage(result.error)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
{config.label}
<input
type={config.inputType}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={config.placeholder}
className="mt-1 w-full rounded border px-3 py-2"
/>
</label>
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
Continue with {auth.name}
</button>
{message && <p className="text-sm">{message}</p>}
</form>
)
}Component-level switch (Rules-of-Hooks safe)
function LoginPage({ method }: { method: AuthStrategyKey }) {
switch (method) {
case 'otp':
return <OtpLoginForm />
case 'sso':
return <SsoLoginButton />
case 'magicLink':
return <MagicLinkForm />
default:
return null
}
}6. Example: Data fetching strategies
Swap how data loads — REST, GraphQL, or in-memory JSON — behind one interface.
type FetchStrategy<T> = {
name: string
fetchList: (resource: string) => Promise<T[]>
fetchOne: (resource: string, id: string) => Promise<T | null>
}
type Product = { id: string; name: string; price: number }
const restStrategy: FetchStrategy<Product> = {
name: 'rest',
async fetchList(resource) {
const res = await fetch(`/api/${resource}`)
if (!res.ok) throw new Error('REST fetch failed')
return res.json()
},
async fetchOne(resource, id) {
const res = await fetch(`/api/${resource}/${id}`)
if (!res.ok) return null
return res.json()
},
}
const graphqlStrategy: FetchStrategy<Product> = {
name: 'graphql',
async fetchList(resource) {
const query = `{ ${resource} { id name price } }`
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
})
const json = await res.json()
return json.data[resource]
},
async fetchOne(resource, id) {
const query = `{ ${resource}(id: "${id}") { id name price } }`
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
})
const json = await res.json()
return json.data[resource]
},
}
const memoryStrategy: FetchStrategy<Product> = {
name: 'memory',
async fetchList(_resource) {
return [
{ id: '1', name: 'Demo Widget', price: 19.99 },
{ id: '2', name: 'Sample Gadget', price: 29.99 },
]
},
async fetchOne(_resource, id) {
const items = await memoryStrategy.fetchList('products')
return items.find((p) => p.id === id) ?? null
},
}
export const fetchRegistry = {
rest: restStrategy,
graphql: graphqlStrategy,
memory: memoryStrategy,
} as const satisfies Record<string, FetchStrategy<Product>>
export type FetchStrategyKey = keyof typeof fetchRegistryConsumer hook with injected strategy
'use client'
import { useCallback, useEffect, useState } from 'react'
import { fetchRegistry, type FetchStrategyKey, type Product } from './fetchRegistry'
function createUseProducts(strategyKey: FetchStrategyKey) {
const strategy = fetchRegistry[strategyKey]
return function useProducts() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
setProducts(await strategy.fetchList('products'))
} catch (e) {
setError(e instanceof Error ? e.message : 'Fetch failed')
} finally {
setLoading(false)
}
}, [strategy])
useEffect(() => {
refetch()
}, [refetch])
return { products, loading, error, refetch, source: strategy.name }
}
}
export const useRestProducts = createUseProducts('rest')
export const useMemoryProducts = createUseProducts('memory')// Storybook / tests — zero network
function ProductListDemo() {
const { products, loading, source } = useMemoryProducts()
if (loading) return <p>Loading from {source}…</p>
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price}
</li>
))}
</ul>
)
}7. Key benefits
| Benefit | Outcome |
|---|---|
| Code maintainability | Edit OTP in one file — SSO untouched |
| Improved readability | Registry + interface beats nested conditionals |
| Reduced bug creation | Isolated strategies limit blast radius of changes |
| Clean consumer components | UI calls login() — no auth implementation imports |
| Testability | Mock strategy object in tests — no fetch mocking in UI |
| Team parallelism | Dev A owns SSO file; Dev B owns OTP — fewer conflicts |
Before vs after — consumer component
// ❌ Consumer knows every auth method
async function handleLogin(method: string, input: string) {
if (method === 'otp') {
/* … */
} else if (method === 'sso') {
/* … */
}
}
// ✅ Consumer depends on abstraction only
async function handleLogin(input: string) {
await auth.login(input)
}8. Best practices & pitfalls
| Practice / pitfall | Guidance |
|---|---|
| Avoid for two branches | Simple if/else once — Strategy adds ceremony |
| Shared interface required | TypeScript satisfies on registry entries |
| No conditional hook calls | Factory exports or component switch |
Registry in lib/strategies/ | Not inside JSX files |
| Document strategy contract | Redirect vs async return, error shapes |
Summary
| Piece | Role |
|---|---|
| Strategy interface | Contract all implementations satisfy |
| Registry | Key → implementation map |
| Selector | Provider, factory, or config picks active |
| Hook factory | React-safe reusable hooks per strategy |
| Consumer | Depends on abstraction — not concrete code |
The Strategy pattern replaces conditional sprawl with pluggable behavior boxes. Use a registry + Context for app-wide auth or fetch modes; use hook factories for testable, isolated custom hooks.
Next reads: State Reducer Pattern for in-feature state machines, Provider Pattern for Context-based strategy injection, 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