FoundryKit

Custom Hook Patterns

Advanced patterns for creating custom hooks with FoundryKit hooks

Custom Hook Patterns

Learn how to create powerful custom hooks by combining FoundryKit hooks with your own logic.

Basic Custom Hook Patterns

Form Management Hook

Create a reusable form hook with validation and persistence:

import { useLocalStorage, useDebouncedValue } from '@foundrykit/hooks'
import { useState, useCallback } from 'react'

interface FormField {
  value: string
  error?: string
  touched: boolean
}

interface FormState {
  [key: string]: FormField
}

function useForm<T extends Record<string, any>>(
  initialValues: T,
  validationSchema?: Record<keyof T, (value: any) => string | undefined>
) {
  const [formData, setFormData] = useLocalStorage('formData', initialValues)
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})

  const setFieldValue = useCallback((field: keyof T, value: any) => {
    setFormData(prev => ({ ...prev, [field]: value }))
    
    // Clear error when field is updated
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: undefined }))
    }
  }, [errors, setFormData])

  const setFieldTouched = useCallback((field: keyof T) => {
    setTouched(prev => ({ ...prev, [field]: true }))
  }, [])

  const validateField = useCallback((field: keyof T, value: any) => {
    if (!validationSchema?.[field]) return undefined
    
    const error = validationSchema[field](value)
    setErrors(prev => ({ ...prev, [field]: error }))
    return error
  }, [validationSchema])

  const validateForm = useCallback(() => {
    const newErrors: Partial<Record<keyof T, string>> = {}
    
    Object.keys(formData).forEach(key => {
      const field = key as keyof T
      const error = validateField(field, formData[field])
      if (error) {
        newErrors[field] = error
      }
    })
    
    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }, [formData, validateField])

  const resetForm = useCallback(() => {
    setFormData(initialValues)
    setErrors({})
    setTouched({})
  }, [initialValues, setFormData])

  return {
    values: formData,
    errors,
    touched,
    setFieldValue,
    setFieldTouched,
    validateField,
    validateForm,
    resetForm,
    isValid: Object.keys(errors).length === 0
  }
}

// Usage
function ContactForm() {
  const validationSchema = {
    name: (value: string) => !value ? 'Name is required' : undefined,
    email: (value: string) => !value ? 'Email is required' : !value.includes('@') ? 'Invalid email' : undefined,
    message: (value: string) => !value ? 'Message is required' : value.length < 10 ? 'Message too short' : undefined
  }

  const { values, errors, touched, setFieldValue, setFieldTouched, validateForm } = useForm({
    name: '',
    email: '',
    message: ''
  }, validationSchema)

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (validateForm()) {
      console.log('Form submitted:', values)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <input
          value={values.name}
          onChange={(e) => setFieldValue('name', e.target.value)}
          onBlur={() => setFieldTouched('name')}
          placeholder="Name"
          className={touched.name && errors.name ? 'border-red-500' : ''}
        />
        {touched.name && errors.name && (
          <p className="text-red-500 text-sm">{errors.name}</p>
        )}
      </div>
      
      <div>
        <input
          value={values.email}
          onChange={(e) => setFieldValue('email', e.target.value)}
          onBlur={() => setFieldTouched('email')}
          placeholder="Email"
          className={touched.email && errors.email ? 'border-red-500' : ''}
        />
        {touched.email && errors.email && (
          <p className="text-red-500 text-sm">{errors.email}</p>
        )}
      </div>
      
      <div>
        <textarea
          value={values.message}
          onChange={(e) => setFieldValue('message', e.target.value)}
          onBlur={() => setFieldTouched('message')}
          placeholder="Message"
          className={touched.message && errors.message ? 'border-red-500' : ''}
        />
        {touched.message && errors.message && (
          <p className="text-red-500 text-sm">{errors.message}</p>
        )}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  )
}

Search Hook with History

Create a search hook with debouncing, history, and suggestions:

import { useLocalStorage, useDebouncedValue } from '@foundrykit/hooks'
import { useState, useEffect, useCallback } from 'react'

interface SearchResult {
  id: string
  title: string
  description: string
}

function useSearchWithHistory() {
  const [query, setQuery] = useLocalStorage('searchQuery', '')
  const [searchHistory, setSearchHistory] = useLocalStorage('searchHistory', [] as string[])
  const debouncedQuery = useDebouncedValue(query, 500)
  
  const [results, setResults] = useState<SearchResult[]>([])
  const [suggestions, setSuggestions] = useState<string[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  // Load suggestions from history
  useEffect(() => {
    if (query && searchHistory.length > 0) {
      const filteredHistory = searchHistory
        .filter(item => item.toLowerCase().includes(query.toLowerCase()))
        .slice(0, 5)
      setSuggestions(filteredHistory)
    } else {
      setSuggestions([])
    }
  }, [query, searchHistory])

  // Perform search with debounced query
  useEffect(() => {
    if (debouncedQuery) {
      setIsLoading(true)
      setError(null)
      
      performSearch(debouncedQuery)
        .then(searchResults => {
          setResults(searchResults)
          // Add to history if not already present
          if (!searchHistory.includes(debouncedQuery)) {
            setSearchHistory(prev => [debouncedQuery, ...prev.slice(0, 9)])
          }
        })
        .catch(err => {
          setError(err.message)
          setResults([])
        })
        .finally(() => setIsLoading(false))
    } else {
      setResults([])
    }
  }, [debouncedQuery, searchHistory, setSearchHistory])

  const clearHistory = useCallback(() => {
    setSearchHistory([])
  }, [setSearchHistory])

  const removeFromHistory = useCallback((itemToRemove: string) => {
    setSearchHistory(prev => prev.filter(item => item !== itemToRemove))
  }, [setSearchHistory])

  const selectSuggestion = useCallback((suggestion: string) => {
    setQuery(suggestion)
    setSuggestions([])
  }, [setQuery])

  return {
    query,
    setQuery,
    debouncedQuery,
    results,
    suggestions,
    isLoading,
    error,
    searchHistory,
    clearHistory,
    removeFromHistory,
    selectSuggestion
  }
}

// Usage
function SearchComponent() {
  const {
    query,
    setQuery,
    results,
    suggestions,
    isLoading,
    error,
    searchHistory,
    clearHistory,
    removeFromHistory,
    selectSuggestion
  } = useSearchWithHistory()

  return (
    <div className="relative">
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
        className="w-full p-2 border rounded"
      />
      
      {suggestions.length > 0 && (
        <div className="absolute top-full left-0 right-0 bg-white border rounded shadow-lg">
          {suggestions.map(suggestion => (
            <div
              key={suggestion}
              onClick={() => selectSuggestion(suggestion)}
              className="p-2 hover:bg-gray-100 cursor-pointer"
            >
              {suggestion}
            </div>
          ))}
        </div>
      )}
      
      {isLoading && <div>Loading...</div>}
      {error && <div className="text-red-500">{error}</div>}
      
      <div className="mt-4">
        {results.map(result => (
          <div key={result.id} className="p-2 border-b">
            <h3>{result.title}</h3>
            <p>{result.description}</p>
          </div>
        ))}
      </div>
      
      {searchHistory.length > 0 && (
        <div className="mt-4">
          <h4>Search History</h4>
          <button onClick={clearHistory}>Clear All</button>
          {searchHistory.map(item => (
            <div key={item} className="flex justify-between items-center">
              <span onClick={() => selectSuggestion(item)} className="cursor-pointer">
                {item}
              </span>
              <button onClick={() => removeFromHistory(item)}>×</button>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

Advanced Custom Hook Patterns

Theme Management Hook

Create a comprehensive theme management system:

import { useLocalStorage, useMediaQuery } from '@foundrykit/hooks'
import { useEffect, useCallback } from 'react'

type Theme = 'light' | 'dark' | 'system'

interface ThemeConfig {
  theme: Theme
  primaryColor: string
  fontSize: 'small' | 'medium' | 'large'
  reducedMotion: boolean
}

function useTheme() {
  const [config, setConfig] = useLocalStorage<ThemeConfig>('themeConfig', {
    theme: 'system',
    primaryColor: '#3b82f6',
    fontSize: 'medium',
    reducedMotion: false
  })
  
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

  // Determine actual theme based on system preference
  const actualTheme = config.theme === 'system' 
    ? (prefersDark ? 'dark' : 'light')
    : config.theme

  // Apply theme to document
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', actualTheme)
    document.documentElement.setAttribute('data-font-size', config.fontSize)
    
    if (config.reducedMotion || prefersReducedMotion) {
      document.documentElement.setAttribute('data-reduced-motion', 'true')
    } else {
      document.documentElement.removeAttribute('data-reduced-motion')
    }
  }, [actualTheme, config.fontSize, config.reducedMotion, prefersReducedMotion])

  // Auto-detect system preference on first load
  useEffect(() => {
    if (!localStorage.getItem('themeConfig')) {
      setConfig(prev => ({
        ...prev,
        theme: 'system',
        reducedMotion: prefersReducedMotion
      }))
    }
  }, [prefersReducedMotion, setConfig])

  const updateTheme = useCallback((theme: Theme) => {
    setConfig(prev => ({ ...prev, theme }))
  }, [setConfig])

  const updatePrimaryColor = useCallback((color: string) => {
    setConfig(prev => ({ ...prev, primaryColor: color }))
  }, [setConfig])

  const updateFontSize = useCallback((size: 'small' | 'medium' | 'large') => {
    setConfig(prev => ({ ...prev, fontSize: size }))
  }, [setConfig])

  const toggleReducedMotion = useCallback(() => {
    setConfig(prev => ({ ...prev, reducedMotion: !prev.reducedMotion }))
  }, [setConfig])

  const resetToDefaults = useCallback(() => {
    setConfig({
      theme: 'system',
      primaryColor: '#3b82f6',
      fontSize: 'medium',
      reducedMotion: prefersReducedMotion
    })
  }, [prefersReducedMotion, setConfig])

  return {
    config,
    actualTheme,
    prefersDark,
    prefersReducedMotion,
    updateTheme,
    updatePrimaryColor,
    updateFontSize,
    toggleReducedMotion,
    resetToDefaults
  }
}

// Usage
function ThemeSettings() {
  const {
    config,
    actualTheme,
    updateTheme,
    updatePrimaryColor,
    updateFontSize,
    toggleReducedMotion,
    resetToDefaults
  } = useTheme()

  return (
    <div className="space-y-4">
      <div>
        <label>Theme</label>
        <select value={config.theme} onChange={(e) => updateTheme(e.target.value as Theme)}>
          <option value="light">Light</option>
          <option value="dark">Dark</option>
          <option value="system">System</option>
        </select>
        <p>Current theme: {actualTheme}</p>
      </div>
      
      <div>
        <label>Primary Color</label>
        <input
          type="color"
          value={config.primaryColor}
          onChange={(e) => updatePrimaryColor(e.target.value)}
        />
      </div>
      
      <div>
        <label>Font Size</label>
        <select value={config.fontSize} onChange={(e) => updateFontSize(e.target.value as any)}>
          <option value="small">Small</option>
          <option value="medium">Medium</option>
          <option value="large">Large</option>
        </select>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            checked={config.reducedMotion}
            onChange={toggleReducedMotion}
          />
          Reduced Motion
        </label>
      </div>
      
      <button onClick={resetToDefaults}>Reset to Defaults</button>
    </div>
  )
}

Responsive Layout Hook

Create a comprehensive responsive layout system:

import { useMediaQuery } from '@foundrykit/hooks'
import { useMemo } from 'react'

interface Breakpoint {
  name: string
  minWidth: number
  maxWidth?: number
}

interface LayoutConfig {
  columns: number
  sidebarWidth: string
  showSidebar: boolean
  showMobileMenu: boolean
  containerMaxWidth: string
  spacing: string
}

function useResponsiveLayout() {
  const breakpoints: Breakpoint[] = [
    { name: 'xs', minWidth: 0, maxWidth: 640 },
    { name: 'sm', minWidth: 641, maxWidth: 768 },
    { name: 'md', minWidth: 769, maxWidth: 1024 },
    { name: 'lg', minWidth: 1025, maxWidth: 1280 },
    { name: 'xl', minWidth: 1281, maxWidth: 1536 },
    { name: '2xl', minWidth: 1537 }
  ]

  // Create media queries for each breakpoint
  const breakpointQueries = useMemo(() => {
    return breakpoints.map(bp => ({
      ...bp,
      query: bp.maxWidth 
        ? `(min-width: ${bp.minWidth}px) and (max-width: ${bp.maxWidth}px)`
        : `(min-width: ${bp.minWidth}px)`
    }))
  }, [])

  // Check each breakpoint
  const activeBreakpoints = breakpointQueries.map(bp => ({
    ...bp,
    isActive: useMediaQuery(bp.query)
  }))

  // Find current breakpoint
  const currentBreakpoint = activeBreakpoints.find(bp => bp.isActive) || breakpoints[0]

  // Additional responsive queries
  const isMobile = useMediaQuery('(max-width: 768px)')
  const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
  const isDesktop = useMediaQuery('(min-width: 1025px)')
  const isLandscape = useMediaQuery('(orientation: landscape)')
  const isPortrait = useMediaQuery('(orientation: portrait)')

  // Generate layout configuration
  const layoutConfig: LayoutConfig = useMemo(() => {
    switch (currentBreakpoint.name) {
      case 'xs':
      case 'sm':
        return {
          columns: 1,
          sidebarWidth: '100%',
          showSidebar: false,
          showMobileMenu: true,
          containerMaxWidth: '100%',
          spacing: '1rem'
        }
      case 'md':
        return {
          columns: 2,
          sidebarWidth: '250px',
          showSidebar: true,
          showMobileMenu: false,
          containerMaxWidth: '1024px',
          spacing: '1.5rem'
        }
      case 'lg':
        return {
          columns: 3,
          sidebarWidth: '300px',
          showSidebar: true,
          showMobileMenu: false,
          containerMaxWidth: '1280px',
          spacing: '2rem'
        }
      case 'xl':
      case '2xl':
        return {
          columns: 4,
          sidebarWidth: '350px',
          showSidebar: true,
          showMobileMenu: false,
          containerMaxWidth: '1536px',
          spacing: '2.5rem'
        }
      default:
        return {
          columns: 1,
          sidebarWidth: '100%',
          showSidebar: false,
          showMobileMenu: true,
          containerMaxWidth: '100%',
          spacing: '1rem'
        }
    }
  }, [currentBreakpoint.name])

  return {
    currentBreakpoint,
    breakpoints: activeBreakpoints,
    isMobile,
    isTablet,
    isDesktop,
    isLandscape,
    isPortrait,
    layoutConfig
  }
}

// Usage
function ResponsiveLayout() {
  const { layoutConfig, currentBreakpoint } = useResponsiveLayout()

  return (
    <div 
      className="container mx-auto"
      style={{ 
        maxWidth: layoutConfig.containerMaxWidth,
        padding: layoutConfig.spacing
      }}
    >
      <div className={`grid grid-cols-${layoutConfig.columns} gap-4`}>
        {layoutConfig.showSidebar && (
          <aside style={{ width: layoutConfig.sidebarWidth }}>
            <div>Sidebar content</div>
          </aside>
        )}
        
        <main className={layoutConfig.showSidebar ? 'col-span-2' : 'col-span-1'}>
          <div>Main content</div>
          <p>Current breakpoint: {currentBreakpoint.name}</p>
        </main>
      </div>
      
      {layoutConfig.showMobileMenu && (
        <div className="fixed bottom-4 right-4">
          <button>Mobile Menu</button>
        </div>
      )}
    </div>
  )
}

Composition Patterns

Hook Factory Pattern

Create reusable hook factories for common patterns:

import { useLocalStorage, useDebouncedValue } from '@foundrykit/hooks'
import { useState, useEffect, useCallback } from 'react'

function createSearchHook<T>(
  searchFunction: (query: string) => Promise<T[]>,
  options: {
    debounceMs?: number
    persistQuery?: boolean
    maxHistoryItems?: number
  } = {}
) {
  const {
    debounceMs = 500,
    persistQuery = true,
    maxHistoryItems = 10
  } = options

  return function useSearch() {
    const [query, setQuery] = persistQuery 
      ? useLocalStorage('searchQuery', '')
      : useState('')
    
    const [history, setHistory] = useLocalStorage('searchHistory', [] as string[])
    const debouncedQuery = useDebouncedValue(query, debounceMs)
    
    const [results, setResults] = useState<T[]>([])
    const [isLoading, setIsLoading] = useState(false)
    const [error, setError] = useState<string | null>(null)

    useEffect(() => {
      if (debouncedQuery) {
        setIsLoading(true)
        setError(null)
        
        searchFunction(debouncedQuery)
          .then(searchResults => {
            setResults(searchResults)
            // Add to history
            if (!history.includes(debouncedQuery)) {
              setHistory(prev => [debouncedQuery, ...prev.slice(0, maxHistoryItems - 1)])
            }
          })
          .catch(err => {
            setError(err.message)
            setResults([])
          })
          .finally(() => setIsLoading(false))
      } else {
        setResults([])
      }
    }, [debouncedQuery, history, setHistory, maxHistoryItems])

    const clearHistory = useCallback(() => {
      setHistory([])
    }, [setHistory])

    return {
      query,
      setQuery,
      debouncedQuery,
      results,
      isLoading,
      error,
      history,
      clearHistory
    }
  }
}

// Usage
const useUserSearch = createSearchHook(
  (query: string) => fetch(`/api/users?q=${query}`).then(res => res.json()),
  { debounceMs: 300, persistQuery: true, maxHistoryItems: 5 }
)

const useProductSearch = createSearchHook(
  (query: string) => fetch(`/api/products?q=${query}`).then(res => res.json()),
  { debounceMs: 500, persistQuery: false }
)

function SearchComponents() {
  const userSearch = useUserSearch()
  const productSearch = useProductSearch()

  return (
    <div className="space-y-4">
      <div>
        <h3>User Search</h3>
        <input
          value={userSearch.query}
          onChange={(e) => userSearch.setQuery(e.target.value)}
          placeholder="Search users..."
        />
        {userSearch.isLoading && <div>Loading users...</div>}
        {userSearch.results.map(user => (
          <div key={user.id}>{user.name}</div>
        ))}
      </div>
      
      <div>
        <h3>Product Search</h3>
        <input
          value={productSearch.query}
          onChange={(e) => productSearch.setQuery(e.target.value)}
          placeholder="Search products..."
        />
        {productSearch.isLoading && <div>Loading products...</div>}
        {productSearch.results.map(product => (
          <div key={product.id}>{product.name}</div>
        ))}
      </div>
    </div>
  )
}

Next Steps