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

  1. Server State: Use Server Components and Server Actions
  2. Local UI State: Use useState in the component
  3. Shared State: Use Context API or prop drilling
  4. Complex State: Create custom hooks
  5. Global App State: Use Context API with providers

Performance Considerations

  • Use useMemo for expensive calculations
  • Use useCallback for 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
  }
}