Geavanceerde React Hooks: Patronen en Optimalisaties

Beheers geavanceerde React Hooks met bewezen patronen. Custom hooks, geoptimaliseerde useEffect, useMemo, useCallback en performancetechnieken.

Illustratie van geavanceerde React Hooks met compositie- en optimalisatiepatronen

React Hooks hebben de manier waarop state en side effects in functionele componenten worden beheerd ingrijpend veranderd. Naast het basisgebruik van useState en useEffect maken geavanceerde patronen het mogelijk om herbruikbare, performante en onderhoudbare code te schrijven.

Vereisten

Deze gids veronderstelt vertrouwdheid met de basis Hooks (useState, useEffect, useContext). De voorbeelden gebruiken React 18+ en TypeScript voor robuuste typering.

useEffect onder de knie krijgen: veelvoorkomende valkuilen vermijden

De hook useEffect wordt vaak verkeerd begrepen en verkeerd toegepast. Een diepgaand begrip van de werking helpt om subtiele bugs en performanceproblemen te voorkomen.

Het fundamentele principe: useEffect synchroniseert een component met een extern systeem. Als het effect niet communiceert met de buitenwereld (API, DOM, timer), is het waarschijnlijk overbodig.

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
}

De meest voorkomende fout is het weglaten van de dependency-array of het opnemen van instabiele waarden. Elke waarde die in het effect wordt gebruikt, moet in de dependencies staan.

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>
  )
}

Dit patroon met AbortController en de isMounted-vlag voorkomt geheugenlekken en state-updates op niet-gemounte componenten.

Herbruikbare custom hooks maken

Custom hooks kapselen herbruikbare logica in. Een goede custom hook volgt het Single Responsibility-principe en biedt een duidelijke API.

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]
}

Deze hook kan in elk component worden gebruikt om gegevens te bewaren.

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>
  )
}
Naamgevingsconventie

Custom hooks beginnen altijd met "use" (useLocalStorage, useFetch, useDebounce). Deze conventie stelt React in staat om de hook-regels te controleren en lintingtools correct te laten werken.

Compositiepatroon met useReducer

Voor complexe state met meerdere mogelijke acties biedt useReducer een voorspelbaardere structuur dan meerdere useState-aanroepen.

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
  }
}

Deze aanpak centraliseert alle winkelwagen-logica en maakt unit tests eenvoudiger.

Klaar om je React / Next.js gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Optimalisatie met useMemo en useCallback

Deze hooks vermijden onnodige berekeningen of het opnieuw aanmaken van functies. Gebruik moet gericht zijn: ze overal toepassen verslechtert de performance in plaats van die te verbeteren.

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>
  )
}
Wanneer useMemo/useCallback gebruiken?

Deze hooks voegen complexiteit toe. Reserveer ze voor: (1) werkelijk dure berekeningen, (2) props doorgegeven aan met memo() gememoized componenten, (3) dependencies van andere hooks.

Debounce-hook voor zoekvelden

Debouncing voorkomt dat een actie te vaak wordt uitgevoerd, typisch bij het typen in een zoekveld.

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>
  )
}

Het component voert de zoekopdracht pas 300 ms uit nadat de gebruiker stopt met typen, waardoor tientallen onnodige requests worden vermeden.

useImperativeHandle voor geavanceerde refs

Deze hook stelt specifieke methoden van een kindcomponent beschikbaar aan het ouder-component via een 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>
  )
}

Dit patroon is bijzonder nuttig voor mediacomponenten, formulieren of elke interface die imperatieve controle vereist.

Hook voor levenscyclusbeheer van requests

Een geavanceerde hook om laad-, fout- en datatoestanden af te handelen met strikte typering.

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
  }
}

Deze hook biedt een complete API om elke request af te handelen met automatische annulering en levenscyclusbeheer.

Klaar om je React / Next.js gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Conclusie

Geavanceerde React Hooks-patronen maken het mogelijk om onderhoudbare en performante code te bouwen. Belangrijkste punten:

  • useEffect: altijd cleanup opnemen en race conditions afhandelen met AbortController
  • Custom hooks: herbruikbare logica encapsuleren met een duidelijke API
  • useReducer: voorkeur voor complexe state met meerdere acties
  • useMemo/useCallback: gericht inzetten, alleen waar nodig
  • useDebounce: frequente uitvoeringen bij zoekopdrachten beperken
  • useImperativeHandle: imperatieve methoden gecontroleerd blootstellen

Het beheersen van deze patronen onderscheidt gevorderde van expert React-ontwikkelaars. Elke hook lost een specifiek probleem op: het juiste gereedschap op het juiste moment kiezen is de sleutel tot een robuuste React-architectuur.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

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

Delen

Gerelateerde artikelen