Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsStrategy PatternHook FactoryCustom Hooks

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.

Jul 5, 202615 min read

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:

  1. Scalability problems — why giant if/else blocks fail
  2. Pattern fundamentals — registry, selector, abstraction
  3. Hook factory — React-safe strategy wiring
  4. Global strategy with Context — Provider-level selection
  5. Authentication strategies — OTP, Google SSO, Magic Link
  6. Data fetching strategies — REST, GraphQL, in-memory JSON
  7. Key benefits — maintainability, testability, clean consumers
  8. Best practices & pitfalls — Rules of Hooks, over-engineering

Note

Strategy swaps how something is done — not what UI renders. Keep consumer components thin; they call strategy.login() or useAuth() without knowing whether the backend is OTP or SSO.

Quick index

#Section
1Core scalability problems
2Strategy pattern fundamentals
3Hook factory pattern
4Global strategy with Context
5Example: Authentication strategies
6Example: Data fetching strategies
7Key benefits
8Best practices & pitfalls

1. Core scalability problems

Before adopting Strategy, recognize what monolithic conditional logic costs as features grow.

ProblemSymptom
Complex if/else conditionsEvery new auth method adds another branch in one hook
Untestable hook logicCannot test OTP path without mounting SSO side effects
Merge conflict headachesMultiple devs edit the same 400-line useAuth file
Hard to extend or maintainChanging Google SSO breaks Magic Link accidentally

Before — strategy sprawl in one hook

tsx
'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 }
}

Warning

Adding a fourth method (passkey, saml) means editing this hook again — risk rises with every branch. Strategy extracts each method into its own behavior box.

Back to index


2. Strategy pattern fundamentals

Four pieces compose every Strategy implementation.

PieceRole
Independent behavior boxesSelf-contained strategy modules — one file per algorithm
Strategy registryConfig object mapping keys → implementations
Strategy selectorFunction or Provider that picks the active strategy
Implementation abstractionShared interface consumers depend on — not concrete code
plaintext
Consumer
  → calls strategy.login(credentials)
  → selector picked "otp" from registry
  → OtpStrategy.login runs
  → consumer never imports OtpStrategy directly

Strategy interface + registry

tsx
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]
}

Tip

Define a shared interface (AuthStrategy, FetchStrategy) first — registry entries must satisfy it so TypeScript catches missing methods at compile time.

Back to index


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 benefitDetail
Avoids conditional hook callsEach exported hook is fixed at creation time
Individual custom hooksuseOtpAuth, useSsoAuth — isolated, testable
Factory returns hook by configcreateUsePersistedState(storageStrategy)
Isolated unitsUnit-test strategy logic without full component

Factory — persisted state example

tsx
'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

tsx
'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')

Warning

Never call hooks from a dynamic lookup at runtime:

tsx
// ❌ Breaks Rules of Hooks if `method` changes between renders
const useAuth = authHookRegistry[method]
return useAuth()

Fix: export separate hooks via factory, switch components (method === 'otp' ? <OtpLogin /> : <SsoLogin />), or put plain strategy objects (not hooks) in Context.

Note

Hook factories create hooks once at module load — useLocalStorage and useSessionStorage are stable identities, safe to call unconditionally in components.

Back to index


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 pieceRole
Context decides locationStrategy selected once at Provider boundary
Provider wraps hierarchyDescendants read the same implementation
Strategy name as prop<AuthProvider strategy="otp"> switches app-wide
Uniform accessuseAuth() — consumers ignore concrete strategy
tsx
'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
}
tsx
// 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>
  )
}

Tip

Pass strategy from config — env var, tenant feature flag, or A/B test — into the Provider at the app shell. Consumers stay unchanged when product switches auth methods.

See Provider Pattern for Context boundaries, memoized values, and split-context performance tips.

Back to index


5. Example: Authentication strategies

Three interchangeable login flows sharing one AuthStrategy interface.

Magic Link strategy

tsx
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

tsx
'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)

tsx
function LoginPage({ method }: { method: AuthStrategyKey }) {
  switch (method) {
    case 'otp':
      return <OtpLoginForm />
    case 'sso':
      return <SsoLoginButton />
    case 'magicLink':
      return <MagicLinkForm />
    default:
      return null
  }
}

Note

SSO often redirects away from the SPA — the strategy's login may not return a session immediately. Document async vs redirect strategies so UI handles each path correctly.

Back to index


6. Example: Data fetching strategies

Swap how data loads — REST, GraphQL, or in-memory JSON — behind one interface.

tsx
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 fetchRegistry

Consumer hook with injected strategy

tsx
'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')
tsx
// 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>
  )
}

Performance

Use memory strategy in Storybook, unit tests, and offline demos — same component tree, zero network latency, deterministic data.

Tip

Wire fetch strategy from environment config — NEXT_PUBLIC_API_MODE=graphql — at the Provider or hook export level, not inside every page component.

Back to index


7. Key benefits

BenefitOutcome
Code maintainabilityEdit OTP in one file — SSO untouched
Improved readabilityRegistry + interface beats nested conditionals
Reduced bug creationIsolated strategies limit blast radius of changes
Clean consumer componentsUI calls login() — no auth implementation imports
TestabilityMock strategy object in tests — no fetch mocking in UI
Team parallelismDev A owns SSO file; Dev B owns OTP — fewer conflicts

Before vs after — consumer component

tsx
// ❌ 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)
}

Interview Answer

"Strategy vs State Reducer?"

Strategy swaps entire algorithms at runtime (OTP vs SSO vs REST vs GraphQL) — different implementations of the same interface. State reducer governs transitions within one state machine (cart add/remove, wizard steps). Use Strategy for pluggable backends; use reducer for predictable state evolution inside one feature.

Back to index


8. Best practices & pitfalls

Practice / pitfallGuidance
Avoid for two branchesSimple if/else once — Strategy adds ceremony
Shared interface requiredTypeScript satisfies on registry entries
No conditional hook callsFactory exports or component switch
Registry in lib/strategies/Not inside JSX files
Document strategy contractRedirect vs async return, error shapes

Warning

Over-engineering: three lines of logic in one component does not need a registry, Provider, and three hook factories. Introduce Strategy when branches multiply or teams parallelize implementations.

Warning

Putting React state inside strategy objects breaks purity — strategies should be stateless functions (or call hooks only inside factory-created hooks). State lives in the hook or component, not the strategy plain object.

Tip

Production checklist

  • Define strategy interface first
  • Registry maps keys → implementations
  • Hook factory for fixed per-strategy hooks
  • Context Provider for app-wide strategy selection
  • Component switch when hook signatures differ per method
  • Memory/mock strategy for tests and Storybook
  • Env or tenant config drives Provider strategy prop
  • Avoid dynamic hooksRegistry[type]() calls

Back to index


Summary

PieceRole
Strategy interfaceContract all implementations satisfy
RegistryKey → implementation map
SelectorProvider, factory, or config picks active
Hook factoryReact-safe reusable hooks per strategy
ConsumerDepends 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.

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