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
- Check out performance optimization techniques
- Review best practices for optimal usage
- Explore the hook reference for detailed usage examples