DevToolBoxGRATIS
Blog

Panduan Lengkap React Hooks: useState, useEffect, dan Custom Hooks

14 menit bacaoleh DevToolBox
TL;DR
  • Hooks are functions that let you use React features in functional components.
  • Rules of Hooks: only call at top level, only call from React functions.
  • useState for local state, useEffect for side effects, useRef for mutable values.
  • useCallback and useMemo optimize performance by memoizing values and functions.
  • Custom hooks extract and share stateful logic between components without adding component hierarchy.

React Hooks, introduced in React 16.8, fundamentally changed how we write React components. Instead of class components with lifecycle methods, we now have a composable, functional approach to managing state, side effects, context, and more. This comprehensive guide covers every built-in hook, custom hook patterns, React 18 concurrent hooks, and practical patterns used in production applications.

Key Takeaways
  • Never call hooks inside loops, conditions, or nested functions.
  • useEffect cleanup functions prevent memory leaks and stale event listeners.
  • Stale closures in useEffect are the most common React bug — always declare dependencies correctly.
  • useCallback and useMemo only help when passing to memoized children or in expensive computations.
  • useReducer is better than multiple useState calls for complex, related state.
  • Custom hooks are just functions that call other hooks — test them independently.
  • React 18 useTransition and useDeferredValue enable non-blocking UI updates.

Rules of Hooks

React enforces two critical rules for hooks. Violating them causes bugs that are hard to diagnose. The ESLint plugin eslint-plugin-react-hooks enforces these rules automatically.

  • Only call hooks at the top level — never inside loops, conditionals, or nested functions.
  • Only call hooks from React function components or custom hooks — not from regular JavaScript functions.

Why These Rules Exist

React relies on the order in which hooks are called to correctly associate state with the component instance. If you call a hook conditionally, the order can change between renders, breaking React's internal state tracking.

// WRONG — hook called conditionally
function BadComponent({ show }: { show: boolean }) {
  if (show) {
    const [count, setCount] = useState(0); // React Error!
  }
  return <div />;
}

// CORRECT — hook always called, condition inside
function GoodComponent({ show }: { show: boolean }) {
  const [count, setCount] = useState(0);

  if (!show) return null;
  return <div>{count}</div>;
}

// WRONG — hook called in a loop
function BadList({ items }: { items: string[] }) {
  return items.map(item => {
    const [active, setActive] = useState(false); // React Error!
    return <span key={item}>{item}</span>;
  });
}

// CORRECT — extract to a component
function ItemComponent({ item }: { item: string }) {
  const [active, setActive] = useState(false);
  return <span onClick={() => setActive(!active)}>{item}</span>;
}

useState: Managing Local State

useState is the most fundamental hook. It returns a stateful value and a function to update it. Every time you call the setter, React re-renders the component with the new state.

Basic Usage

import { useState } from 'react';

function Counter() {
  // [currentValue, setterFunction] = useState(initialValue)
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={() => setIsVisible(v => !v)}>Toggle</button>
      {isVisible && <p>Hello, {name}!</p>}
    </div>
  );
}

Functional Updates

When new state depends on the previous state, use the functional update form. This guarantees you are working with the most recent state, avoiding bugs in concurrent scenarios.

function SafeCounter() {
  const [count, setCount] = useState(0);

  // BAD: uses 'count' from closure — can be stale in async context
  const handleBad = () => {
    setCount(count + 1);
    setCount(count + 1); // Both use stale 'count', result: +1 not +2
  };

  // GOOD: functional update — always uses latest state
  const handleGood = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1); // Result: +2 as expected
  };

  // GOOD: async scenarios benefit especially from functional updates
  const handleAsync = async () => {
    await someAsyncOperation();
    setCount(prev => prev + 1); // Safe: uses state at call time
  };

  return <button onClick={handleGood}>Count: {count}</button>;
}

Lazy Initialization

If computing the initial state is expensive, pass a function to useState. The function runs only on the first render, not on every re-render.

// BAD: getExpensiveInitialValue() runs on every render
function BadComponent() {
  const [data, setData] = useState(getExpensiveInitialValue());
  return <div>{data}</div>;
}

// GOOD: lazy initializer — function runs once on mount only
function GoodComponent() {
  const [data, setData] = useState(() => getExpensiveInitialValue());
  return <div>{data}</div>;
}

// Practical: reading from localStorage on init
function PersistentCounter() {
  const [count, setCount] = useState(() => {
    if (typeof window === 'undefined') return 0;
    const stored = localStorage.getItem('count');
    return stored ? parseInt(stored, 10) : 0;
  });

  const increment = () => {
    setCount(prev => {
      const next = prev + 1;
      localStorage.setItem('count', String(next));
      return next;
    });
  };

  return <button onClick={increment}>Count: {count}</button>;
}

Object State — Always Spread

useState does not merge objects like this.setState did in class components. You must spread the existing state manually or you will lose other properties.

interface FormState {
  name: string;
  email: string;
  age: number;
}

function UserForm() {
  const [form, setForm] = useState<FormState>({ name: '', email: '', age: 0 });

  // BAD: replaces entire state — loses other fields
  const handleNameBad = (name: string) => {
    setForm({ name } as FormState); // Missing email and age!
  };

  // GOOD: spread existing state, override changed field
  const handleChange = (field: keyof FormState, value: string | number) => {
    setForm(prev => ({ ...prev, [field]: value }));
  };

  return (
    <form>
      <input value={form.name} onChange={e => handleChange('name', e.target.value)} />
      <input value={form.email} onChange={e => handleChange('email', e.target.value)} />
      <input type="number" value={form.age}
        onChange={e => handleChange('age', parseInt(e.target.value))} />
    </form>
  );
}

useEffect: Handling Side Effects

useEffect runs after every render by default. It handles side effects: API calls, subscriptions, DOM manipulation, timers, and anything that interacts with the outside world.

Dependency Array

The second argument controls when the effect runs. An empty array means "run once after mount." An array with values means "run when those values change." Omitting the array means "run after every render."

import { useEffect, useState } from 'react';

function EffectExamples({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [time, setTime] = useState(new Date());

  // Runs after EVERY render (no deps array)
  useEffect(() => {
    document.title = 'Page updated';
  });

  // Runs ONCE after mount (empty array)
  useEffect(() => {
    console.log('Component mounted');
    return () => console.log('Component unmounted');
  }, []);

  // Runs when userId changes
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]); // Re-runs whenever userId changes

  // Timer with cleanup
  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []); // Timer set up once, cleaned up on unmount

  return <div>{time.toLocaleTimeString()}</div>;
}

Cleanup Functions

Return a function from useEffect to clean up subscriptions, timers, or event listeners. React calls the cleanup before re-running the effect and when the component unmounts.

function SubscriptionComponent({ channel }: { channel: string }) {
  const [messages, setMessages] = useState<string[]>([]);

  // WebSocket cleanup
  useEffect(() => {
    const socket = new WebSocket(`wss://api.example.com/${channel}`);
    socket.addEventListener('message', event => {
      setMessages(prev => [...prev, event.data]);
    });
    return () => socket.close(); // Cleanup on unmount or channel change
  }, [channel]);

  // Event listener cleanup
  useEffect(() => {
    const handleResize = () => console.log('resized');
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // AbortController for fetch cleanup
  useEffect(() => {
    const controller = new AbortController();
    fetch('/api/data', { signal: controller.signal })
      .then(r => r.json())
      .then(data => setMessages(data))
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });
    return () => controller.abort(); // Cancel in-flight request
  }, [channel]);

  return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}

Stale Closures — The #1 Pitfall

A stale closure occurs when an effect captures an outdated value. The effect's callback is created during render and closes over the values at that time. If dependencies are missing, the effect never sees updated values.

// BUG: stale closure — count is always 0 in the effect
function StaleClosure() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('count:', count); // Always prints 0! Stale closure.
      setCount(count + 1);          // Always sets to 1, never increments
    }, 1000);
    return () => clearInterval(id);
  }, []); // Missing 'count' dependency

  return <p>{count}</p>;
}

// FIX 1: Add count to dependencies (re-creates interval each update)
function FixedV1() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]); // Correct but heavy
  return <p>{count}</p>;
}

// FIX 2: Use functional update (preferred)
function FixedV2() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1); // Always uses latest state — no dep needed
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <p>{count}</p>;
}

// FIX 3: useRef for callbacks needed in effects
function FixedV3({ onTick }: { onTick: (count: number) => void }) {
  const [count, setCount] = useState(0);
  const onTickRef = useRef(onTick);
  onTickRef.current = onTick; // Always up to date

  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => {
        const next = prev + 1;
        onTickRef.current(next); // Latest callback, stable interval
        return next;
      });
    }, 1000);
    return () => clearInterval(id);
  }, []); // onTick not in deps — no stale closure

  return <p>{count}</p>;
}

useRef: DOM Refs and Mutable Values

useRef returns a mutable object with a .current property. It persists across renders without triggering re-renders — unlike state. Use it for DOM references or any mutable value you need to track without causing re-renders.

DOM References

import { useRef, useEffect } from 'react';

function FocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
  const divRef = useRef<HTMLDivElement>(null);

  // Focus on mount
  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const handlePlay = () => videoRef.current?.play();

  // Measuring DOM elements
  const measureHeight = () => {
    if (divRef.current) {
      const rect = divRef.current.getBoundingClientRect();
      console.log('Height:', rect.height, 'Width:', rect.width);
    }
  };

  return (
    <div>
      <input ref={inputRef} placeholder="Auto-focused on mount" />
      <video ref={videoRef} src="/video.mp4" />
      <button onClick={handlePlay}>Play</button>
      <div ref={divRef} onClick={measureHeight}>Measure me</div>
    </div>
  );
}

Mutable Values Without Re-render

Storing a value in useRef does not cause re-renders. Use it for interval IDs, previous values, or any tracking variable.

function StopwatchWithRef() {
  const [display, setDisplay] = useState(0);
  const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const startTimeRef = useRef<number>(0);
  const renderCountRef = useRef(0); // Tracks renders without causing them

  renderCountRef.current += 1; // Does NOT trigger re-render

  const start = () => {
    startTimeRef.current = Date.now() - display;
    intervalIdRef.current = setInterval(() => {
      setDisplay(Date.now() - startTimeRef.current);
    }, 10);
  };

  const stop = () => {
    if (intervalIdRef.current) {
      clearInterval(intervalIdRef.current);
      intervalIdRef.current = null;
    }
  };

  return (
    <div>
      <p>Time: {(display / 1000).toFixed(2)}s</p>
      <p>Renders: {renderCountRef.current}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

// usePrevious pattern
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => { ref.current = value; });
  return ref.current; // Returns value from previous render
}

forwardRef: Exposing Refs to Parents

By default, ref props are not passed to child components. Use forwardRef to expose a DOM node or component API to the parent.

import { forwardRef } from 'react';

// Basic forwardRef
const FancyInput = forwardRef<HTMLInputElement, { placeholder?: string }>(
  ({ placeholder }, ref) => (
    <input
      ref={ref}
      placeholder={placeholder}
      style={{ border: '2px solid #0ea5e9', borderRadius: 4, padding: 8 }}
    />
  )
);
FancyInput.displayName = 'FancyInput'; // Recommended for React DevTools

// Parent using the forwarded ref
function ParentComponent() {
  const inputRef = useRef<HTMLInputElement>(null);
  const focusInput = () => inputRef.current?.focus();

  return (
    <div>
      <FancyInput ref={inputRef} placeholder="Fancy input" />
      <button onClick={focusInput}>Focus</button>
    </div>
  );
}

useCallback: Memoizing Functions

useCallback returns a memoized version of a callback function. The memoized function only changes when one of the dependencies changes. This is critical when passing callbacks to deeply nested or memoized child components.

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

// Child is memoized — re-renders only when props change by reference
const MemoChild = memo(({ onClick }: { onClick: () => void }) => {
  console.log('Child rendered');
  return <button onClick={onClick}>Click me</button>;
});

function Parent({ userId }: { userId: string }) {
  const [count, setCount] = useState(0);

  // BAD: new function every render — memo on child is useless
  const handleClickBad = () => {
    console.log('clicked', userId);
  };

  // GOOD: stable reference — only recreated when userId changes
  const handleClickGood = useCallback(() => {
    console.log('clicked', userId);
  }, [userId]);

  // useCallback as useEffect dependency
  const fetchData = useCallback(async () => {
    const data = await fetch(`/api/users/${userId}`);
    return data.json();
  }, [userId]); // Stable — only changes when userId changes

  useEffect(() => {
    fetchData().then(console.log);
  }, [fetchData]); // Effect re-runs only when userId changes

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment parent</button>
      <MemoChild onClick={handleClickGood} />
    </div>
  );
}

When to Use useCallback

useCallback has a cost: it adds complexity and the memoization computation itself. Only use it when the callback is passed to a React.memo-wrapped child or is listed as a useEffect dependency.

useMemo: Memoizing Computed Values

useMemo memoizes the result of a computation and only recomputes it when dependencies change. Use it for expensive calculations or when creating objects/arrays that are passed as props to memoized children.

import { useMemo, useState } from 'react';

interface Product { id: string; name: string; price: number; active: boolean; }

function ProductList({ items, filter }: { items: Product[]; filter: string }) {
  // Recomputes only when items or filter change
  const filteredItems = useMemo(
    () => items.filter(item =>
      item.active && item.name.toLowerCase().includes(filter.toLowerCase())
    ),
    [items, filter]
  );

  // Expensive derived stats
  const stats = useMemo(() => {
    if (filteredItems.length === 0) return { total: 0, avg: 0, max: 0 };
    const prices = filteredItems.map(i => i.price);
    return {
      total: filteredItems.length,
      avg: prices.reduce((s, p) => s + p, 0) / prices.length,
      max: Math.max(...prices),
    };
  }, [filteredItems]);

  return (
    <div>
      <p>Found: {stats.total} | Avg: ${stats.avg.toFixed(2)} | Max: ${stats.max}</p>
      {filteredItems.map(item => <div key={item.id}>{item.name} — ${item.price}</div>)}
    </div>
  );
}

// useMemo for referential stability in useEffect deps
function DataComponent({ url, method }: { url: string; method: string }) {
  // GOOD: stabilize object to avoid infinite effect loop
  const fetchOptions = useMemo(
    () => ({ url, method }),
    [url, method]
  );

  useEffect(() => {
    fetch(fetchOptions.url, { method: fetchOptions.method });
  }, [fetchOptions]); // Stable reference — no infinite loop

  return null;
}

When useMemo Helps

useMemo is not free — it has overhead. Profile first. Use it for genuinely expensive calculations (filtering thousands of items, complex transformations) or to maintain referential stability for objects/arrays used as effect dependencies.

useContext: Consuming Context

useContext subscribes to React context and returns its current value. Every component that calls useContext re-renders whenever the context value changes. Compose context with custom hooks for a clean API.

Context + Custom Hook Pattern

import { createContext, useContext, useState, useMemo, ReactNode } from 'react';

// 1. Define context type
interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// 2. Create context — null helps detect missing provider
const ThemeContext = createContext<ThemeContextValue | null>(null);

// 3. Custom hook — throws if used outside provider
export function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

// 4. Provider with memoized value
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const toggleTheme = useCallback(() => setTheme(t => t === 'light' ? 'dark' : 'light'), []);

  // Memoize to prevent re-rendering all consumers on every parent render
  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// 5. Consumer — clean, type-safe API
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button
      style={{
        background: theme === 'dark' ? '#1e293b' : '#f1f5f9',
        color: theme === 'dark' ? '#f8fafc' : '#1e293b',
        padding: '8px 16px', borderRadius: 6, border: 'none', cursor: 'pointer'
      }}
      onClick={toggleTheme}
    >
      Theme: {theme}
    </button>
  );
}

useReducer: Complex State Management

useReducer is a useState alternative for complex state logic. It takes a reducer function and an initial state, returning the current state and a dispatch function. It is the local equivalent of Redux-style state management.

import { useReducer } from 'react';

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

interface CartState {
  items: CartItem[];
  total: number;
  isLoading: boolean;
  error: string | null;
}

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: 'SET_LOADING'; payload: boolean }
  | { type: 'SET_ERROR'; payload: string };

const calculateTotal = (items: CartItem[]) =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0);

// Pure reducer — easy to test in isolation
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id);
      const items = existing
        ? state.items.map(i => i.id === action.payload.id
            ? { ...i, quantity: i.quantity + 1 } : i)
        : [...state.items, { ...action.payload, quantity: 1 }];
      return { ...state, items, total: calculateTotal(items) };
    }
    case 'REMOVE_ITEM': {
      const items = state.items.filter(i => i.id !== action.payload);
      return { ...state, items, total: calculateTotal(items) };
    }
    case 'UPDATE_QUANTITY': {
      const items = state.items.map(i =>
        i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
      );
      return { ...state, items, total: calculateTotal(items) };
    }
    case 'CLEAR_CART':
      return { ...state, items: [], total: 0 };
    case 'SET_LOADING':
      return { ...state, isLoading: action.payload };
    case 'SET_ERROR':
      return { ...state, error: action.payload, isLoading: false };
    default:
      return state;
  }
}

const initialState: CartState = { items: [], total: 0, isLoading: false, error: null };

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, initialState);

  return (
    <div>
      <p>Total: ${cart.total.toFixed(2)} ({cart.items.length} items)</p>
      {cart.items.map(item => (
        <div key={item.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <span>{item.name}</span>
          <button onClick={() => dispatch({ type: 'UPDATE_QUANTITY',
            payload: { id: item.id, quantity: item.quantity - 1 } })}>-</button>
          <span>{item.quantity}</span>
          <button onClick={() => dispatch({ type: 'UPDATE_QUANTITY',
            payload: { id: item.id, quantity: item.quantity + 1 } })}>+</button>
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
            Remove
          </button>
        </div>
      ))}
      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>Clear Cart</button>
    </div>
  );
}

When to Use useReducer

  • State has multiple sub-values that change together
  • Next state depends on previous state in complex ways
  • State transitions have names (action types) that make code readable
  • Testing: reducers are pure functions — easy to unit test

Custom Hooks: Extracting Reusable Logic

Custom hooks are JavaScript functions whose name starts with "use" and that may call other hooks. They are the primary mechanism for sharing stateful logic between components without adding component layers (no render props or HOCs needed).

useFetch: Data Fetching Hook

import { useState, useEffect, useCallback } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [trigger, setTrigger] = useState(0);

  const refetch = useCallback(() => setTrigger(t => t + 1), []);

  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err as Error);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url, trigger]);

  return { data, loading, error, refetch };
}

// Usage
interface User { id: string; name: string; email: string; }

function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error, refetch } = useFetch<User>(
    `/api/users/${userId}`
  );

  if (loading) return <div>Loading...</div>;
  if (error) return (
    <div>
      Error: {error.message}
      <button onClick={refetch}>Retry</button>
    </div>
  );
  return <div>{user?.name} — {user?.email}</div>;
}

useDebounce: Debounce Hook

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer); // Clear on value/delay change
  }, [value, delay]);

  return debouncedValue;
}

// Usage: search input — only fires API call after user stops typing
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300); // 300ms debounce

  const { data: results } = useFetch<string[]>(
    debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : ''
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search... (debounced)"
      />
      {query !== debouncedQuery && <span>Typing...</span>}
      {results?.map((r, i) => <div key={i}>{r}</div>)}
    </div>
  );
}

useLocalStorage: Persistent State Hook

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  // Lazy init: read from localStorage once on mount
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const item = localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value: T | ((prev: T) => T)) => {
    try {
      const newValue = value instanceof Function ? value(storedValue) : value;
      setStoredValue(newValue);
      if (typeof window !== 'undefined') {
        localStorage.setItem(key, JSON.stringify(newValue));
      }
    } catch (error) {
      console.error(`useLocalStorage error for key "${key}":`, error);
    }
  }, [key, storedValue]);

  const removeValue = useCallback(() => {
    setStoredValue(initialValue);
    localStorage.removeItem(key);
  }, [key, initialValue]);

  return [storedValue, setValue, removeValue] as const;
}

// Usage
function ThemeToggle() {
  const [theme, setTheme, resetTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
  return (
    <div>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Theme: {theme}
      </button>
      <button onClick={resetTheme}>Reset</button>
    </div>
  );
}

useForm: Form State Hook

import { useState, useCallback } from 'react';

interface FormConfig<T extends Record<string, unknown>> {
  initialValues: T;
  validate?: (values: T) => Partial<Record<keyof T, string>>;
  onSubmit: (values: T) => void | Promise<void>;
}

function useForm<T extends Record<string, unknown>>({
  initialValues, validate, onSubmit
}: FormConfig<T>) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  const [submitting, setSubmitting] = useState(false);

  const handleChange = useCallback((field: keyof T, value: unknown) => {
    setValues(prev => ({ ...prev, [field]: value }));
    if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }));
  }, [errors]);

  const handleBlur = useCallback((field: keyof T) => {
    setTouched(prev => ({ ...prev, [field]: true }));
  }, []);

  const handleSubmit = useCallback(async (e: React.FormEvent) => {
    e.preventDefault();
    if (validate) {
      const validationErrors = validate(values);
      if (Object.keys(validationErrors).length > 0) {
        setErrors(validationErrors);
        return;
      }
    }
    setSubmitting(true);
    try { await onSubmit(values); }
    finally { setSubmitting(false); }
  }, [values, validate, onSubmit]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return { values, errors, touched, submitting, handleChange, handleBlur, handleSubmit, reset };
}

// Usage
function LoginForm() {
  const { values, errors, touched, submitting, handleChange, handleBlur, handleSubmit } = useForm({
    initialValues: { email: '', password: '' } as { email: string; password: string },
    validate: ({ email, password }) => {
      const e: Record<string, string> = {};
      if (!email.includes('@')) e.email = 'Invalid email address';
      if (password.length < 8) e.password = 'Password must be at least 8 characters';
      return e;
    },
    onSubmit: async (values) => {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error('Login failed');
    },
  });

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <div>
        <input
          type="email"
          value={values.email}
          onChange={e => handleChange('email', e.target.value)}
          onBlur={() => handleBlur('email')}
          placeholder="Email"
        />
        {touched.email && errors.email && (
          <span style={{ color: '#ef4444', fontSize: 13 }}>{errors.email}</span>
        )}
      </div>
      <div>
        <input
          type="password"
          value={values.password}
          onChange={e => handleChange('password', e.target.value)}
          onBlur={() => handleBlur('password')}
          placeholder="Password"
        />
        {touched.password && errors.password && (
          <span style={{ color: '#ef4444', fontSize: 13 }}>{errors.password}</span>
        )}
      </div>
      <button type="submit" disabled={submitting}>
        {submitting ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
}

useTransition and useDeferredValue (React 18)

React 18 introduced concurrent rendering. useTransition marks state updates as non-urgent, letting React interrupt them to handle more urgent updates. useDeferredValue defers re-rendering a part of the UI.

import { useState, useTransition, useDeferredValue, memo } from 'react';

// useTransition: mark updates as non-urgent
function TabSwitcher() {
  const [activeTab, setActiveTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  const tabs = ['home', 'posts', 'contact', 'settings'];

  const selectTab = (tab: string) => {
    startTransition(() => {
      setActiveTab(tab); // Non-urgent — React can interrupt to handle user input
    });
  };

  return (
    <div>
      <div style={{ display: 'flex', gap: 8 }}>
        {tabs.map(tab => (
          <button
            key={tab}
            onClick={() => selectTab(tab)}
            style={{
              opacity: isPending ? 0.6 : 1,
              fontWeight: activeTab === tab ? 700 : 400,
            }}
          >
            {tab}
          </button>
        ))}
      </div>
      {isPending && <span style={{ color: '#94a3b8' }}>Loading...</span>}
      <HeavyTabContent tab={activeTab} />
    </div>
  );
}

// useDeferredValue: defer re-rendering slow parts
function SearchPage() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  // query vs deferredQuery tells us if results are stale
  const isStale = query !== deferredQuery;

  return (
    <div>
      {/* Input always stays responsive */}
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      {/* Results may briefly show stale data while updating */}
      <div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}>
        <SlowSearchResults query={deferredQuery} />
      </div>
    </div>
  );
}

When to Use Concurrent Hooks

  • useTransition: search-as-you-type, tab switching, large list filtering
  • useDeferredValue: when you cannot modify the state update that triggers re-renders

useId, useLayoutEffect, useImperativeHandle

useId generates unique IDs that are stable across server and client, solving hydration mismatches. Perfect for accessibility attributes like htmlFor/id pairs.

import { useId, useLayoutEffect, useImperativeHandle, forwardRef, useRef, useState } from 'react';

// useId: stable IDs for accessibility — no hydration mismatch
function FormField({ label, type = 'text' }: { label: string; type?: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type={type} />
    </div>
  );
}

// Multiple IDs from one useId call
function MultiFieldForm() {
  const id = useId();
  return (
    <form>
      <label htmlFor={`${id}-email`}>Email</label>
      <input id={`${id}-email`} type="email" />
      <label htmlFor={`${id}-name`}>Name</label>
      <input id={`${id}-name`} />
      <span id={`${id}-hint`}>Enter your full name</span>
      <input aria-describedby={`${id}-hint`} />
    </form>
  );
}

// useLayoutEffect: synchronous DOM measurement before paint
function Tooltip({ children, text }: { children: React.ReactNode; text: string }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLSpanElement>(null);

  // Runs before browser paints — prevents position flicker
  useLayoutEffect(() => {
    if (tooltipRef.current && triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      const tipRect = tooltipRef.current.getBoundingClientRect();
      setPosition({
        top: rect.top - tipRect.height - 8,
        left: rect.left + rect.width / 2 - tipRect.width / 2,
      });
    }
  });

  return (
    <>
      <span ref={triggerRef}>{children}</span>
      <div ref={tooltipRef}
        style={{ position: 'fixed', top: position.top, left: position.left,
          background: '#1e293b', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
        {text}
      </div>
    </>
  );
}

// useImperativeHandle: expose specific API through ref
interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
  seek: (time: number) => void;
  getTime: () => number;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
  ({ src }, ref) => {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play: () => videoRef.current?.play(),
      pause: () => videoRef.current?.pause(),
      seek: (time) => { if (videoRef.current) videoRef.current.currentTime = time; },
      getTime: () => videoRef.current?.currentTime ?? 0,
    }), []); // Empty deps: API functions are stable

    return <video ref={videoRef} src={src} controls />;
  }
);
VideoPlayer.displayName = 'VideoPlayer';

function VideoController() {
  const playerRef = useRef<VideoPlayerHandle>(null);
  return (
    <div>
      <VideoPlayer ref={playerRef} src="/demo.mp4" />
      <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
        <button onClick={() => playerRef.current?.play()}>Play</button>
        <button onClick={() => playerRef.current?.pause()}>Pause</button>
        <button onClick={() => playerRef.current?.seek(0)}>Restart</button>
      </div>
    </div>
  );
}

React Hooks vs Class Lifecycle Methods

Understanding the mapping between hooks and class lifecycle methods helps when migrating existing code or understanding React's mental model.

Class LifecycleHooks EquivalentNotes
constructoruseState / useReducerInitial state passed as argument to useState
componentDidMountuseEffect(() => {}, [])Empty deps array — runs once after first render
componentDidUpdateuseEffect(() => {}, [dep])List dependencies — runs when they change
componentWillUnmountuseEffect cleanupReturn a function from useEffect
shouldComponentUpdateReact.memo + useMemomemo wraps component, useMemo for computed values
getDerivedStateFromPropsCompute directly during renderAvoid derived state — compute inline
getSnapshotBeforeUpdateuseLayoutEffectRead layout synchronously after DOM update, before paint
componentDidCatchNo Hook equivalent (class component required)Error Boundaries still require class components
this.setStateuseState / useReducerHooks do not auto-merge objects — spread manually
this.forceUpdateuseReducer dispatchDispatch any action to force a re-render

Common Anti-Patterns to Avoid

Missing Dependencies in useEffect

Always include all reactive values used inside effects in the dependency array. The ESLint rule react-hooks/exhaustive-deps catches these mistakes.

// BAD: missing 'query' dependency — stale closure
function SearchBad({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    // 'query' is used but not listed in deps
    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, []); // ESLint: react-hooks/exhaustive-deps warning!

  return <ul>{results.map((r, i) => <li key={i}>{r}</li>)}</ul>;
}

// GOOD: all reactive values in deps
function SearchGood({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setResults)
      .catch(err => { if (err.name !== 'AbortError') console.error(err); });
    return () => controller.abort();
  }, [query]); // Correct — re-runs when query changes

  return <ul>{results.map((r, i) => <li key={i}>{r}</li>)}</ul>;
}

Object/Array in Dependency Array

Objects and arrays are compared by reference. Creating them inline in JSX creates a new reference every render, causing infinite effect loops.

// BAD: object/array literal in deps — new reference every render → infinite loop
function BadEffect({ userId }: { userId: string }) {
  useEffect(() => {
    fetch('/api/data', { body: JSON.stringify({ userId, version: 2 }) });
  }, [{ userId, version: 2 }]); // New object reference every render!
}

// GOOD: use primitive values as dependencies
function GoodEffect({ userId }: { userId: string }) {
  useEffect(() => {
    fetch('/api/data', { body: JSON.stringify({ userId, version: 2 }) });
  }, [userId]); // Primitive string — stable comparison

  // Alternative: memoize the object if you must use it directly
  const requestBody = useMemo(() => ({ userId, version: 2 }), [userId]);
  useEffect(() => {
    fetch('/api/data', { body: JSON.stringify(requestBody) });
  }, [requestBody]); // Stable reference now
}

Overusing useCallback and useMemo

Wrapping everything in useCallback and useMemo adds complexity without benefit if children are not memoized. Profile first, then optimize.

// OVER-OPTIMIZATION: adds complexity without benefit
function OverOptimized({ items }: { items: string[] }) {
  // Useless: just reading a property — zero computation cost
  const itemCount = useMemo(() => items.length, [items]);

  // Useless: child is not wrapped in memo — stability doesn't matter
  const handleClick = useCallback(() => console.log('click'), []);

  return <div onClick={handleClick}>{itemCount} items</div>;
}

// APPROPRIATE optimization
const ExpensiveChild = memo(({ onClick }: { onClick: () => void }) => {
  // Imagine heavy rendering logic
  return <button onClick={onClick}>Action</button>;
});

function WellOptimized({ products }: { products: Product[] }) {
  // Justified: filtering thousands of items is expensive
  const activeProducts = useMemo(
    () => products.filter(p => p.active && p.price > 0).sort((a, b) =>
      a.name.localeCompare(b.name)),
    [products]
  );

  // Justified: passed to memoized child
  const handleAdd = useCallback((id: string) => addToCart(id), []);

  return (
    <div>
      {activeProducts.map(p => (
        <ExpensiveChild key={p.id} onClick={() => handleAdd(p.id)} />
      ))}
    </div>
  );
}

State That Can Be Derived

Do not store derived values in state. Compute them during render. Derived state causes synchronization bugs.

// BAD: derived state — synchronization nightmare
interface User { firstName: string; lastName: string; items: string[]; }

function BadComponent({ user }: { user: User }) {
  const [fullName, setFullName] = useState('');
  const [sortedItems, setSortedItems] = useState<string[]>([]);

  // Synchronization bugs: what if user prop changes?
  useEffect(() => {
    setFullName(`${user.firstName} ${user.lastName}`);
  }, [user.firstName, user.lastName]);

  useEffect(() => {
    setSortedItems([...user.items].sort());
  }, [user.items]);

  return <div>{fullName}</div>;
}

// GOOD: compute during render — always in sync
function GoodComponent({ user }: { user: User }) {
  // Compute synchronously — no state, no effect, no sync issues
  const fullName = `${user.firstName} ${user.lastName}`;

  // useMemo only if genuinely expensive
  const sortedItems = useMemo(
    () => [...user.items].sort(),
    [user.items]
  );

  return (
    <div>
      <h2>{fullName}</h2>
      <ul>{sortedItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

Frequently Asked Questions

What is the difference between useEffect and useLayoutEffect?

useEffect runs asynchronously after the browser has painted the screen. useLayoutEffect runs synchronously after DOM mutations but before painting. Use useLayoutEffect only when you need to prevent visual flicker, such as measuring DOM elements and adjusting layout. For data fetching, subscriptions, and most side effects, useEffect is the correct choice.

When should I use useReducer instead of useState?

Use useReducer when: you have multiple related state values that change together, the next state depends on the previous state in complex ways, you want to name state transitions (action types) for readability, or you want to test state logic independently. useState is sufficient for simple, independent values.

Why does my useEffect run twice in development?

In React 18 Strict Mode, effects are intentionally mounted, unmounted, and remounted to detect side effects that are not properly cleaned up. This only happens in development, not in production. It helps you find bugs like missing cleanup functions or subscriptions that leak.

How do I fetch data on component mount with hooks?

Use useEffect with an empty dependency array to run once after mount. Create an async function inside the effect and call it immediately. Always handle cleanup with an AbortController to cancel requests if the component unmounts before the request completes. Consider libraries like SWR or React Query for production data fetching.

What causes infinite re-render loops with useEffect?

Infinite loops occur when an effect updates state that is listed as a dependency of the same effect. Another common cause is including object or array literals in the dependency array — they create new references every render. Fix by stabilizing references with useRef, useMemo, or useCallback, or by restructuring the effect.

Can I call hooks conditionally?

No. React requires hooks to be called in the same order on every render. You cannot call hooks inside if statements, loops, or nested functions. If you want conditional behavior, put the condition inside the hook: call useEffect unconditionally but return early inside the effect body if a condition is not met.

What is the difference between useCallback and useMemo?

useCallback(fn, deps) memoizes a function and returns the same function reference until dependencies change. useMemo(() => value, deps) memoizes the return value of a function. useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Use useCallback for functions, useMemo for computed values.

How do custom hooks differ from regular functions?

Custom hooks can call other hooks (useState, useEffect, etc.) — regular functions cannot. By convention, custom hooks start with "use" so React and ESLint can enforce the Rules of Hooks. Custom hooks enable you to share stateful logic between components without adding component hierarchy (no render props or HOCs).

𝕏 Twitterin LinkedIn
Apakah ini membantu?

Tetap Update

Dapatkan tips dev mingguan dan tool baru.

Tanpa spam. Berhenti kapan saja.

Coba Alat Terkait

TSJSON to TypeScriptJSXHTML to JSX{ }JSON Formatter

Artikel Terkait

TypeScript Generics Dijelaskan: Panduan Praktis dengan Contoh

Kuasai TypeScript generics dari dasar hingga pola lanjutan.

Cheat Sheet Metode Array JavaScript

Referensi lengkap metode array JavaScript: map, filter, reduce, find, some, every dan lainnya dengan contoh.

Cheat Sheet TypeScript Utility Types: Partial, Pick, Omit, dan Lainnya

Referensi lengkap TypeScript utility types dengan contoh praktis. Pelajari Partial, Required, Pick, Omit, Record, Exclude, Extract, ReturnType, dan pola lanjutan.