React Hooks Avanzati: Pattern e Ottimizzazioni

Padroneggia i React Hooks avanzati con pattern collaudati. Custom hooks, useEffect ottimizzato, useMemo, useCallback e tecniche di performance.

Illustrazione di React Hooks avanzati con pattern di composizione e ottimizzazione

I React Hooks hanno rivoluzionato il modo di gestire stato ed effetti collaterali nei componenti funzionali. Oltre all'uso di base di useState e useEffect, i pattern avanzati permettono di creare codice riutilizzabile, performante e manutenibile.

Prerequisiti

Questa guida presuppone una conoscenza di base degli Hooks (useState, useEffect, useContext). Gli esempi utilizzano React 18+ e TypeScript per una tipizzazione robusta.

Padroneggiare useEffect: evitare le trappole comuni

L'hook useEffect è spesso frainteso e mal utilizzato. Comprenderne a fondo il funzionamento aiuta a evitare bug sottili e problemi di performance.

Il principio fondamentale: useEffect sincronizza un componente con un sistema esterno. Se l'effetto non comunica con l'esterno (API, DOM, timer), probabilmente non è necessario.

hooks/useDocumentTitle.tstsx
// Custom hook to synchronize document title
import { useEffect } from 'react'

export function useDocumentTitle(title: string) {
  useEffect(() => {
    // Save the previous title
    const previousTitle = document.title
    // Update the title
    document.title = title

    // Cleanup: restore previous title on unmount
    return () => {
      document.title = previousTitle
    }
  }, [title]) // Only trigger when title changes
}

L'errore più comune consiste nell'omettere l'array delle dipendenze o includere valori instabili. Ogni valore utilizzato nell'effetto deve essere elencato nelle dipendenze.

components/UserProfile.tsxtsx
'use client'

import { useEffect, useState } from 'react'

interface User {
  id: string
  name: string
  email: string
}

interface UserProfileProps {
  userId: string
}

export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    // Flag to prevent state updates after unmount
    let isMounted = true
    // Controller to cancel pending requests
    const controller = new AbortController()

    async function fetchUser() {
      setLoading(true)
      setError(null)

      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })

        if (!response.ok) {
          throw new Error('User not found')
        }

        const data = await response.json()

        // Check if component is still mounted
        if (isMounted) {
          setUser(data)
        }
      } catch (err) {
        // Ignore abort errors
        if (err instanceof Error && err.name !== 'AbortError') {
          if (isMounted) {
            setError(err.message)
          }
        }
      } finally {
        if (isMounted) {
          setLoading(false)
        }
      }
    }

    fetchUser()

    // Cleanup: abort request and mark as unmounted
    return () => {
      isMounted = false
      controller.abort()
    }
  }, [userId]) // Re-run effect when userId changes

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
  if (!user) return null

  return (
    <div className="p-4 border rounded-lg">
      <h2 className="text-xl font-bold">{user.name}</h2>
      <p className="text-gray-600">{user.email}</p>
    </div>
  )
}

Questo pattern con AbortController e flag isMounted previene memory leak e aggiornamenti di stato su componenti smontati.

Creare custom hooks riutilizzabili

I custom hooks incapsulano logica riutilizzabile. Un buon custom hook segue il principio di responsabilità singola ed espone un'API chiara.

hooks/useLocalStorage.tstsx
// Generic hook to persist state in localStorage
import { useState, useEffect, useCallback } from 'react'

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  // Initialize state with stored value or default
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue
    }

    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.warn(`Error reading localStorage "${key}":`, error)
      return initialValue
    }
  })

  // Sync with localStorage on every change
  useEffect(() => {
    if (typeof window === 'undefined') return

    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue))
    } catch (error) {
      console.warn(`Error writing localStorage "${key}":`, error)
    }
  }, [key, storedValue])

  // Function to update value
  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    setStoredValue((prev) => {
      const nextValue = value instanceof Function ? value(prev) : value
      return nextValue
    })
  }, [])

  // Function to remove value
  const removeValue = useCallback(() => {
    setStoredValue(initialValue)
    if (typeof window !== 'undefined') {
      window.localStorage.removeItem(key)
    }
  }, [key, initialValue])

  return [storedValue, setValue, removeValue]
}

Questo hook può essere utilizzato in qualsiasi componente per persistere dati.

components/ThemeToggle.tsxtsx
'use client'

import { useLocalStorage } from '@/hooks/useLocalStorage'

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

export function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage<Theme>('theme', 'system')

  return (
    <select
      value={theme}
      onChange={(e) => setTheme(e.target.value as Theme)}
      className="px-3 py-2 border rounded-lg"
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="system">System</option>
    </select>
  )
}
Convenzione di denominazione

I custom hooks iniziano sempre con "use" (useLocalStorage, useFetch, useDebounce). Questa convenzione consente a React di verificare le regole degli hooks e agli strumenti di linting di funzionare correttamente.

Pattern di composizione con useReducer

Per stati complessi con più azioni possibili, useReducer fornisce una struttura più prevedibile rispetto a molteplici chiamate a useState.

hooks/useCart.tstsx
// Cart management hook with useReducer
import { useReducer, useCallback, useMemo } from 'react'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  isOpen: boolean
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR_CART' }
  | { type: 'TOGGLE_CART' }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find(
        (item) => item.id === action.payload.id
      )

      if (existingItem) {
        // Increment quantity if item exists
        return {
          ...state,
          items: state.items.map((item) =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        }
      }

      // Add new item
      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }]
      }
    }

    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter((item) => item.id !== action.payload)
      }

    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map((item) =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ).filter((item) => item.quantity > 0)
      }

    case 'CLEAR_CART':
      return { ...state, items: [] }

    case 'TOGGLE_CART':
      return { ...state, isOpen: !state.isOpen }

    default:
      return state
  }
}

const initialState: CartState = {
  items: [],
  isOpen: false
}

export function useCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState)

  // Memoized actions to avoid re-renders
  const addItem = useCallback(
    (item: Omit<CartItem, 'quantity'>) => {
      dispatch({ type: 'ADD_ITEM', payload: item })
    },
    []
  )

  const removeItem = useCallback((id: string) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id })
  }, [])

  const updateQuantity = useCallback((id: string, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } })
  }, [])

  const clearCart = useCallback(() => {
    dispatch({ type: 'CLEAR_CART' })
  }, [])

  const toggleCart = useCallback(() => {
    dispatch({ type: 'TOGGLE_CART' })
  }, [])

  // Memoized derived values
  const total = useMemo(
    () => state.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    ),
    [state.items]
  )

  const itemCount = useMemo(
    () => state.items.reduce((sum, item) => sum + item.quantity, 0),
    [state.items]
  )

  return {
    items: state.items,
    isOpen: state.isOpen,
    total,
    itemCount,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    toggleCart
  }
}

Questo approccio centralizza tutta la logica del carrello e semplifica i test unitari.

Pronto a superare i tuoi colloqui su React / Next.js?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Ottimizzazione con useMemo e useCallback

Questi hooks evitano calcoli o ricreazioni di funzioni inutili. Il loro utilizzo deve essere mirato: applicarli ovunque degrada la performance invece di migliorarla.

components/ProductList.tsxtsx
'use client'

import { useState, useMemo, useCallback, memo } from 'react'

interface Product {
  id: string
  name: string
  price: number
  category: string
  inStock: boolean
}

interface ProductListProps {
  products: Product[]
}

// Memoized child component
const ProductCard = memo(function ProductCard({
  product,
  onAddToCart
}: {
  product: Product
  onAddToCart: (id: string) => void
}) {
  console.log(`Render ProductCard: ${product.name}`)

  return (
    <div className="border rounded-lg p-4">
      <h3 className="font-medium">{product.name}</h3>
      <p className="text-gray-600">${product.price}</p>
      <button
        onClick={() => onAddToCart(product.id)}
        disabled={!product.inStock}
        className="mt-2 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {product.inStock ? 'Add to Cart' : 'Out of Stock'}
      </button>
    </div>
  )
})

export function ProductList({ products }: ProductListProps) {
  const [filter, setFilter] = useState('')
  const [sortBy, setSortBy] = useState<'name' | 'price'>('name')
  const [cart, setCart] = useState<string[]>([])

  // useMemo: memoize result of expensive computation
  const filteredAndSortedProducts = useMemo(() => {
    console.log('Computing filteredAndSortedProducts')

    let result = products

    // Filter by name
    if (filter) {
      result = result.filter((p) =>
        p.name.toLowerCase().includes(filter.toLowerCase())
      )
    }

    // Sort
    result = [...result].sort((a, b) => {
      if (sortBy === 'name') {
        return a.name.localeCompare(b.name)
      }
      return a.price - b.price
    })

    return result
  }, [products, filter, sortBy]) // Recompute only when these deps change

  // useCallback: memoize a function
  const handleAddToCart = useCallback((productId: string) => {
    setCart((prev) => [...prev, productId])
  }, []) // Empty deps: function never changes

  // Memoized statistics
  const stats = useMemo(
    () => ({
      total: filteredAndSortedProducts.length,
      inStock: filteredAndSortedProducts.filter((p) => p.inStock).length,
      avgPrice:
        filteredAndSortedProducts.reduce((sum, p) => sum + p.price, 0) /
          filteredAndSortedProducts.length || 0
    }),
    [filteredAndSortedProducts]
  )

  return (
    <div>
      <div className="mb-4 flex gap-4">
        <input
          type="text"
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
          placeholder="Search..."
          className="px-3 py-2 border rounded-lg"
        />
        <select
          value={sortBy}
          onChange={(e) => setSortBy(e.target.value as 'name' | 'price')}
          className="px-3 py-2 border rounded-lg"
        >
          <option value="name">Name</option>
          <option value="price">Price</option>
        </select>
      </div>

      <p className="text-sm text-gray-600 mb-4">
        {stats.total} products ({stats.inStock} in stock) -
        Average price: ${stats.avgPrice.toFixed(2)}
      </p>

      <div className="grid grid-cols-3 gap-4">
        {filteredAndSortedProducts.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>
    </div>
  )
}
Quando usare useMemo/useCallback?

Questi hooks aggiungono complessità. Conviene riservarli a: (1) calcoli realmente costosi, (2) prop passate a componenti memoizzati con memo(), (3) dipendenze di altri hooks.

Hook di debounce per i campi di ricerca

Il debounce evita che un'azione venga eseguita troppo frequentemente, tipicamente durante la digitazione in un campo di ricerca.

hooks/useDebounce.tstsx
// Generic debounce hook
import { useState, useEffect } from 'react'

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    // Create a timer that updates the value after delay
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    // Cleanup: cancel timer if value changes before delay
    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}
components/SearchInput.tsxtsx
'use client'

import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'

interface SearchResult {
  id: string
  title: string
}

export function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
  const [loading, setLoading] = useState(false)

  // Debounce the query by 300ms
  const debouncedQuery = useDebounce(query, 300)

  useEffect(() => {
    // Don't search if query is empty
    if (!debouncedQuery.trim()) {
      setResults([])
      return
    }

    async function search() {
      setLoading(true)

      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(debouncedQuery)}`
        )
        const data = await response.json()
        setResults(data.results)
      } catch (error) {
        console.error('Search error:', error)
      } finally {
        setLoading(false)
      }
    }

    search()
  }, [debouncedQuery]) // Only triggered when debouncedQuery changes

  return (
    <div className="relative">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
        className="w-full px-4 py-2 border rounded-lg"
      />

      {loading && (
        <div className="absolute right-3 top-2.5">
          <div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
        </div>
      )}

      {results.length > 0 && (
        <ul className="absolute w-full mt-1 bg-white border rounded-lg shadow-lg">
          {results.map((result) => (
            <li
              key={result.id}
              className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
            >
              {result.title}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Il componente esegue la ricerca solo 300 ms dopo che l'utente smette di digitare, evitando decine di richieste inutili.

useImperativeHandle per ref avanzate

Questo hook espone metodi specifici di un componente figlio al genitore tramite una ref.

components/VideoPlayer.tsxtsx
'use client'

import {
  useRef,
  useImperativeHandle,
  forwardRef,
  useState,
  useCallback
} from 'react'

// Interface of exposed methods
export interface VideoPlayerRef {
  play: () => void
  pause: () => void
  seek: (time: number) => void
  getCurrentTime: () => number
}

interface VideoPlayerProps {
  src: string
  poster?: string
}

export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
  function VideoPlayer({ src, poster }, ref) {
    const videoRef = useRef<HTMLVideoElement>(null)
    const [isPlaying, setIsPlaying] = useState(false)

    // Expose only desired methods to parent
    useImperativeHandle(
      ref,
      () => ({
        play: () => {
          videoRef.current?.play()
          setIsPlaying(true)
        },
        pause: () => {
          videoRef.current?.pause()
          setIsPlaying(false)
        },
        seek: (time: number) => {
          if (videoRef.current) {
            videoRef.current.currentTime = time
          }
        },
        getCurrentTime: () => {
          return videoRef.current?.currentTime ?? 0
        }
      }),
      [] // No deps: methods always use current ref
    )

    const handlePlayPause = useCallback(() => {
      if (isPlaying) {
        videoRef.current?.pause()
      } else {
        videoRef.current?.play()
      }
      setIsPlaying(!isPlaying)
    }, [isPlaying])

    return (
      <div className="relative">
        <video
          ref={videoRef}
          src={src}
          poster={poster}
          className="w-full rounded-lg"
          onPlay={() => setIsPlaying(true)}
          onPause={() => setIsPlaying(false)}
        />
        <button
          onClick={handlePlayPause}
          className="absolute bottom-4 left-4 px-4 py-2 bg-black/50 text-white rounded"
        >
          {isPlaying ? 'Pause' : 'Play'}
        </button>
      </div>
    )
  }
)
components/VideoController.tsxtsx
'use client'

import { useRef } from 'react'
import { VideoPlayer, VideoPlayerRef } from './VideoPlayer'

export function VideoController() {
  const playerRef = useRef<VideoPlayerRef>(null)

  const handleSkipForward = () => {
    if (playerRef.current) {
      const currentTime = playerRef.current.getCurrentTime()
      playerRef.current.seek(currentTime + 10)
    }
  }

  return (
    <div className="space-y-4">
      <VideoPlayer
        ref={playerRef}
        src="/videos/demo.mp4"
        poster="/images/poster.jpg"
      />

      <div className="flex gap-2">
        <button
          onClick={() => playerRef.current?.play()}
          className="px-4 py-2 bg-green-600 text-white rounded"
        >
          Play
        </button>
        <button
          onClick={() => playerRef.current?.pause()}
          className="px-4 py-2 bg-red-600 text-white rounded"
        >
          Pause
        </button>
        <button
          onClick={handleSkipForward}
          className="px-4 py-2 bg-blue-600 text-white rounded"
        >
          +10s
        </button>
      </div>
    </div>
  )
}

Questo pattern è particolarmente utile per componenti multimediali, form o qualsiasi interfaccia che richieda un controllo imperativo.

Hook di gestione del ciclo di vita delle richieste

Un hook avanzato per gestire stati di caricamento, errore e dati con tipizzazione stretta.

hooks/useFetch.tstsx
// Generic hook for HTTP requests
import { useState, useEffect, useCallback, useRef } from 'react'

interface UseFetchState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

interface UseFetchOptions {
  immediate?: boolean // Execute immediately on mount
  onSuccess?: <T>(data: T) => void
  onError?: (error: Error) => void
}

export function useFetch<T>(
  url: string,
  options: UseFetchOptions = {}
) {
  const { immediate = true, onSuccess, onError } = options

  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: immediate,
    error: null
  })

  // Ref to prevent updates after unmount
  const mountedRef = useRef(true)
  // Ref for abort controller
  const abortControllerRef = useRef<AbortController | null>(null)

  const execute = useCallback(async () => {
    // Abort previous request if in progress
    abortControllerRef.current?.abort()
    abortControllerRef.current = new AbortController()

    setState((prev) => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url, {
        signal: abortControllerRef.current.signal
      })

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }

      const data: T = await response.json()

      if (mountedRef.current) {
        setState({ data, loading: false, error: null })
        onSuccess?.(data)
      }

      return data
    } catch (error) {
      // Ignore abort errors
      if (error instanceof Error && error.name === 'AbortError') {
        return null
      }

      const err = error instanceof Error ? error : new Error('Unknown error')

      if (mountedRef.current) {
        setState({ data: null, loading: false, error: err })
        onError?.(err)
      }

      return null
    }
  }, [url, onSuccess, onError])

  const reset = useCallback(() => {
    setState({ data: null, loading: false, error: null })
  }, [])

  // Execute on mount if immediate=true
  useEffect(() => {
    if (immediate) {
      execute()
    }

    return () => {
      mountedRef.current = false
      abortControllerRef.current?.abort()
    }
  }, [execute, immediate])

  return {
    ...state,
    execute,
    reset,
    isIdle: !state.loading && !state.data && !state.error
  }
}

Questo hook fornisce un'API completa per gestire qualsiasi richiesta con cancellazione automatica e gestione del ciclo di vita.

Pronto a superare i tuoi colloqui su React / Next.js?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Conclusione

I pattern avanzati dei React Hooks permettono di costruire codice manutenibile e performante. Punti chiave da ricordare:

  • useEffect: includere sempre il cleanup e gestire le race condition con AbortController
  • Custom hooks: incapsulare logica riutilizzabile con un'API chiara
  • useReducer: preferire per stati complessi con più azioni
  • useMemo/useCallback: usare in modo mirato, solo quando necessario
  • useDebounce: limitare le esecuzioni frequenti per le ricerche
  • useImperativeHandle: esporre metodi imperativi in modo controllato

La padronanza di questi pattern distingue gli sviluppatori React intermedi dagli esperti. Ogni hook risolve un problema specifico: scegliere lo strumento giusto al momento giusto è la chiave per un'architettura React solida.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

#react hooks
#useEffect
#custom hooks
#performance
#react patterns

Condividi

Articoli correlati