React Hooks Tingkat Lanjut: Pola dan Optimasi

Kuasai React Hooks tingkat lanjut dengan pola yang teruji. Custom hooks, useEffect teroptimasi, useMemo, useCallback, dan teknik performa.

Ilustrasi React Hooks tingkat lanjut dengan pola komposisi dan optimasi

React Hooks mengubah cara state dan side effect dikelola pada komponen fungsional. Selain penggunaan dasar useState dan useEffect, pola tingkat lanjut memungkinkan pembuatan kode yang dapat digunakan kembali, performan, dan mudah dipelihara.

Prasyarat

Panduan ini mengasumsikan keakraban dengan Hooks dasar (useState, useEffect, useContext). Contoh menggunakan React 18+ dan TypeScript untuk tipe yang andal.

Menguasai useEffect: menghindari jebakan umum

Hook useEffect sering disalahpahami dan disalahgunakan. Pemahaman mendalam tentang cara kerjanya membantu menghindari bug halus dan masalah performa.

Prinsip dasar: useEffect menyinkronkan komponen dengan sistem eksternal. Jika efek tidak berkomunikasi dengan dunia luar (API, DOM, timer), kemungkinan besar tidak diperlukan.

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
}

Kesalahan paling umum adalah mengabaikan array dependency atau memasukkan nilai yang tidak stabil. Setiap nilai yang digunakan dalam efek harus tercantum dalam dependency.

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

Pola dengan AbortController dan flag isMounted ini mencegah memory leak dan pembaruan state pada komponen yang sudah di-unmount.

Membuat custom hooks yang dapat digunakan kembali

Custom hooks merangkum logika yang dapat digunakan kembali. Custom hook yang baik mematuhi prinsip tanggung jawab tunggal dan menyediakan API yang jelas.

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

Hook ini dapat dipakai di komponen mana pun untuk menyimpan data secara persisten.

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

Custom hooks selalu diawali dengan "use" (useLocalStorage, useFetch, useDebounce). Konvensi ini memungkinkan React memverifikasi aturan hook dan tooling linting bekerja dengan benar.

Pola komposisi dengan useReducer

Untuk state kompleks dengan beberapa kemungkinan aksi, useReducer memberikan struktur yang lebih dapat diprediksi dibanding banyak panggilan 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
  }
}

Pendekatan ini memusatkan seluruh logika keranjang dan mempermudah unit test.

Siap menguasai wawancara React / Next.js Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Optimasi dengan useMemo dan useCallback

Hook ini menghindari komputasi atau pembuatan ulang fungsi yang tidak perlu. Penggunaannya harus terarah: menerapkannya di mana-mana justru menurunkan performa.

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>
  )
}
Kapan menggunakan useMemo/useCallback?

Hook ini menambah kompleksitas. Sebaiknya disediakan untuk: (1) komputasi yang benar-benar mahal, (2) prop yang diteruskan ke komponen yang dimemoisasi dengan memo(), (3) dependency dari hook lain.

Hook debounce untuk kolom pencarian

Debounce mencegah suatu aksi dijalankan terlalu sering, biasanya saat mengetik di kolom pencarian.

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

Komponen baru menjalankan pencarian 300 ms setelah pengguna berhenti mengetik, sehingga puluhan request yang tidak perlu dapat dihindari.

useImperativeHandle untuk ref tingkat lanjut

Hook ini mengekspos metode tertentu dari komponen anak ke komponen induk melalui 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>
  )
}

Pola ini sangat berguna untuk komponen media, formulir, atau antarmuka mana pun yang membutuhkan kontrol imperatif.

Hook untuk pengelolaan siklus hidup request

Hook tingkat lanjut untuk mengelola state loading, error, dan data dengan tipe yang ketat.

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

Hook ini menyediakan API lengkap untuk menangani request apa pun dengan pembatalan otomatis dan pengelolaan siklus hidup.

Siap menguasai wawancara React / Next.js Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Kesimpulan

Pola React Hooks tingkat lanjut memungkinkan terciptanya kode yang mudah dipelihara dan performan. Poin-poin penting yang perlu diingat:

  • useEffect: selalu sertakan cleanup dan tangani race condition dengan AbortController
  • Custom hooks: rangkum logika yang dapat digunakan kembali dengan API yang jelas
  • useReducer: lebih disukai untuk state kompleks dengan banyak aksi
  • useMemo/useCallback: gunakan secara terarah, hanya bila perlu
  • useDebounce: batasi eksekusi yang sering pada pencarian
  • useImperativeHandle: ekspos metode imperatif secara terkendali

Penguasaan pola-pola ini membedakan developer React menengah dari yang ahli. Setiap hook menyelesaikan masalah tertentu: memilih alat yang tepat pada saat yang tepat adalah kunci arsitektur React yang kokoh.

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tag

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

Bagikan

Artikel terkait