State Management
State management patterns using React hooks, Server Components, and Context API
State Management
Learn how to manage state effectively in a Next.js application using Server Components, React hooks, and context patterns.
Server State with Server Components
Server Components handle server state naturally by fetching data at render time:
// Server components handle server state naturally
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<ProductList products={products} />
</div>
)
}
Server Actions for Mutations
// app/products/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const supabase = await createClient()
const { data, error } = await supabase
.from('products')
.insert({
name: formData.get('name'),
sku: formData.get('sku'),
price: Number(formData.get('price')),
})
.select()
.single()
if (error) {
throw new Error(error.message)
}
revalidatePath('/products')
return data
}
Client State with React Hooks
Optimistic Updates
'use client'
import { useState, useOptimistic } from 'react'
export function OptimisticProductList({ initialProducts }) {
const [products, addOptimisticProduct] = useOptimistic(
initialProducts,
(state, newProduct) => [...state, { ...newProduct, pending: true }]
)
async function createProduct(formData) {
const newProduct = {
id: Date.now(),
name: formData.get('name'),
// ... other fields
}
addOptimisticProduct(newProduct)
try {
await createProductAction(formData)
} catch (error) {
// Handle error - optimistic update will be reverted
}
}
return (
<form action={createProduct}>
{/* Form fields */}
<ProductGrid products={products} />
</form>
)
}
Local Component State
'use client'
import { useState, useEffect } from 'react'
export function ProductFilter({ onFilterChange }) {
const [filters, setFilters] = useState({
search: '',
category: '',
priceRange: [0, 1000],
})
const [debouncedSearch, setDebouncedSearch] = useState(filters.search)
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(filters.search)
}, 300)
return () => clearTimeout(timer)
}, [filters.search])
// Apply filters when debounced search changes
useEffect(() => {
onFilterChange({
...filters,
search: debouncedSearch,
})
}, [debouncedSearch, filters.category, filters.priceRange])
return (
<div className="space-y-4">
<input
type="text"
placeholder="Search products..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
/>
{/* Other filter controls */}
</div>
)
}
Global State with Context
Settings Context
// contexts/settings-context.tsx
'use client'
import { createContext, useContext, useState, useCallback } from 'react'
interface Settings {
theme: 'light' | 'dark'
currency: string
timezone: string
notifications: boolean
}
interface SettingsContextType {
settings: Settings
updateSetting: (key: keyof Settings, value: any) => void
resetSettings: () => void
}
const SettingsContext = createContext<SettingsContextType | null>(null)
const defaultSettings: Settings = {
theme: 'light',
currency: 'USD',
timezone: 'UTC',
notifications: true,
}
export function SettingsProvider({
children,
initialSettings = defaultSettings
}: {
children: React.ReactNode
initialSettings?: Settings
}) {
const [settings, setSettings] = useState<Settings>(initialSettings)
const updateSetting = useCallback((key: keyof Settings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }))
// Persist to localStorage
localStorage.setItem('app-settings', JSON.stringify({
...settings,
[key]: value,
}))
}, [settings])
const resetSettings = useCallback(() => {
setSettings(defaultSettings)
localStorage.removeItem('app-settings')
}, [])
return (
<SettingsContext.Provider value={{ settings, updateSetting, resetSettings }}>
{children}
</SettingsContext.Provider>
)
}
export function useSettings() {
const context = useContext(SettingsContext)
if (!context) {
throw new Error('useSettings must be used within SettingsProvider')
}
return context
}
Usage in Components
'use client'
import { useSettings } from '@/contexts/settings-context'
export function ThemeToggle() {
const { settings, updateSetting } = useSettings()
return (
<button
onClick={() => updateSetting('theme', settings.theme === 'light' ? 'dark' : 'light')}
>
Switch to {settings.theme === 'light' ? 'dark' : 'light'} theme
</button>
)
}
State Management Patterns
Custom Hooks for Complex State
// hooks/use-product-manager.ts
'use client'
import { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
export function useProductManager(initialProducts: Product[]) {
const [products, setProducts] = useState(initialProducts)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const addProduct = useCallback(async (productData: Partial<Product>) => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(productData),
})
if (!response.ok) {
throw new Error('Failed to create product')
}
const newProduct = await response.json()
setProducts(prev => [...prev, newProduct])
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}, [router])
const updateProduct = useCallback(async (id: string, updates: Partial<Product>) => {
setLoading(true)
setError(null)
try {
const response = await fetch(`/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
throw new Error('Failed to update product')
}
const updatedProduct = await response.json()
setProducts(prev =>
prev.map(product =>
product.id === id ? updatedProduct : product
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}, [])
const deleteProduct = useCallback(async (id: string) => {
setLoading(true)
setError(null)
try {
const response = await fetch(`/api/products/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete product')
}
setProducts(prev => prev.filter(product => product.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}, [])
return {
products,
loading,
error,
addProduct,
updateProduct,
deleteProduct,
}
}
Best Practices
State Location Decision Tree
- Server State: Use Server Components and Server Actions
- Local UI State: Use
useStatein the component - Shared State: Use Context API or prop drilling
- Complex State: Create custom hooks
- Global App State: Use Context API with providers
Performance Considerations
- Use
useMemofor expensive calculations - Use
useCallbackfor function dependencies - Minimize Context re-renders by splitting contexts
- Use React DevTools to identify unnecessary re-renders
Error Boundaries
// components/error-boundary.tsx
'use client'
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Error boundary caught an error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-4 border border-red-200 rounded-md bg-red-50">
<h2 className="text-lg font-semibold text-red-800">Something went wrong</h2>
<p className="text-red-600">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
</div>
)
}
return this.props.children
}
}