Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz: programista blogger
Paweł Łukasiewicz
2026-02-03
Udostępnij Udostępnij Kontakt
Wprowadzenie

W poprzednich wpisach poznaliśmy wbudowane Hooki React: useState, useEffect, useContext, useReducer. Używaliśmy ich w komponentach do zarządzania stanem, efektami ubocznymi i kontekstem. Ale szybko zauważysz, że ta sama logika powtarza się w wielu komponentach – fetch danych z API, localStorage, debouncing, zarządzanie formularzem, obsługa media queries.

Kopiowanie tej samej logiki między komponentami jest męczące, podatne na błędy i trudne w utrzymaniu. Na szczęście React daje nam potężne narzędzie: Custom Hooks – sposób na wyciągnięcie reużywalnej logiki z komponentów i użycie jej w wielu miejscach.

W tym wpisie nauczymy się tworzyć własne Hooki, poznamy zasady ich pisania, zbudujemy praktyczne przykłady (useFetch, useLocalStorage, useDebounce, useMediaQuery i więcej), a także nauczymy się jak je testować. To będzie przełomowy wpis – po nim Twój kod stanie się czystszy, bardziej modularny i łatwiejszy w utrzymaniu!

Czym są Custom Hooks?

Custom Hook to funkcja JavaScript, która:

  1. Zaczyna się od use (konwencja, ale wymagana!)
  2. Może używać innych Hooków (useState, useEffect, useContext, innych custom hooks)
  3. Zwraca dane i/lub funkcje (dowolny format)
  4. Musi przestrzegać Rules of Hooks
function useCustomHook() {
  const [state, setState] = useState();
  
  useEffect(() => {
    // logika
  }, []);

  return { state, setState };
}

Dlaczego to potężne?

  • Wyciągasz złożoną logikę z komponentów
  • Reużywasz ją w wielu miejscach
  • Testujesz ją osobno
  • Komponenty stają się prostsze i czytelniejsze
Rules of Hooks (przypomnienie)

Custom Hooki muszą przestrzegać tych samych zasad co wbudowane Hooki:

  1. Wywołuj Hooki tylko na top level – nie w ifach, pętlach, zagnieżdżonych funkcjach
  2. Wywołuj Hooki tylko w funkcjach React – komponenty lub custom hooks
  3. Nazwa zaczyna się od use – React rozpoznaje Hooki po nazwie
// ❌ ŹLE
function useCustomHook() {
  if (condition) {
    const [state, setState] = useState(); // NIE!
  }
}

// ✅ DOBRZE
function useCustomHook() {
  const [state, setState] = useState();
  
  if (condition) {
    // logika używająca state
  }
}
Prosty przykład: useToggle

Zacznijmy od prostego Hooka:

import { useState } from 'react';

function useToggle(initialValue: boolean = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue);

  function toggle() {
    setValue(prev => !prev);
  }

  return [value, toggle];
}

export default useToggle;

Użycie:

function Modal() {
  const [isOpen, toggleOpen] = useToggle(false);

  return (
    <div>
      <button onClick={toggleOpen}>
        {isOpen ? 'Zamknij' : 'Otwórz'} Modal
      </button>
      {isOpen && (
        <div className="modal">
          <h2>Modal Content</h2>
          <button onClick={toggleOpen}>Zamknij</button>
        </div>
      )}
    </div>
  );
}

Ile razy w aplikacji potrzebujesz toggle? Zamiast pisać useState + funkcję toggle w każdym komponencie, masz reużywalny Hook!

useLocalStorage – synchronizacja z localStorage

Jeden z najbardziej praktycznych custom hooks:

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
  // State do przechowywania wartości
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      // Pobierz z localStorage
      const item = window.localStorage.getItem(key);
      // Parse i zwróć lub użyj initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error loading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // Zapisz do localStorage gdy wartość się zmienia
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(`Error saving localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

export default useLocalStorage;

Użycie:

function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [language, setLanguage] = useLocalStorage('language', 'pl');

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Jasny</option>
        <option value="dark">Ciemny</option>
      </select>
      <select value={language} onChange={(e) => setLanguage(e.target.value)}>
        <option value="pl">Polski</option>
        <option value="en">English</option>
      </select>
    </div>
  );
}

Odśwież stronę – ustawienia są zachowane! Cała logika localStorage w jednym miejscu.

useFetch – pobieranie danych z API

Fetch danych to powtarzalna logika – loading, error, data. Wyciągnijmy ją do Hooka:

import { useState, useEffect } from 'react';

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

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

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const result = await response.json();

        if (!cancelled) {
          setData(result);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : 'Unknown error');
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url, refetchIndex]);

  function refetch() {
    setRefetchIndex(prev => prev + 1);
  }

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

export default useFetch;

Użycie:

interface Post {
  id: number;
  title: string;
  body: string;
}

function Posts() {
  const { data, loading, error, refetch } = useFetch<Post[]>(
    'https://jsonplaceholder.typicode.com/posts'
  );

  if (loading) return <div>Ładowanie...</div>;
  if (error) return <div>Błąd: {error}</div>;

  return (
    <div>
      <button onClick={refetch}>Odśwież</button>
      <ul>
        {data?.slice(0, 5).map(post => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Jeden Hook zastępuje dziesiątki linii kodu w każdym komponencie!

useDebounce – opóźnianie wartości

Debouncing to technika opóźniania aktualizacji wartości – przydatne dla search inputów:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // Ustaw timeout żeby zaktualizować debouncedValue
    const timeoutId = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup – anuluj timeout jeśli value się zmieni
    return () => {
      clearTimeout(timeoutId);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

Użycie:

function SearchUsers() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);
  
  const { data, loading } = useFetch<User[]>(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : ''
  );

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Szukaj użytkowników..."
      />
      {loading && <p>Szukam...</p>}
      <ul>
        {data?.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Search nie uruchamia się przy każdej literze – czeka 500ms po zaprzestaniu pisania!

useWindowSize – wymiary okna

Responsywność w JavaScript:

import { useState, useEffect } from 'react';

interface WindowSize {
  width: number;
  height: number;
}

function useWindowSize(): WindowSize {
  const [windowSize, setWindowSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    window.addEventListener('resize', handleResize);
    
    // Cleanup
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return windowSize;
}

export default useWindowSize;

Użycie:

function ResponsiveComponent() {
  const { width, height } = useWindowSize();

  return (
    <div>
      <p>Wymiary okna: {width} x {height}px</p>
      {width < 768 ? (
        <MobileView />
      ) : width < 1024 ? (
        <TabletView />
      ) : (
        <DesktopView />
      )}
    </div>
  );
}
useMediaQuery – CSS media queries w JS

Bardziej deklaratywny sposób na responsywność:

import { useState, useEffect } from 'react';

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    
    function handleChange(event: MediaQueryListEvent) {
      setMatches(event.matches);
    }

    // Modern browsers
    mediaQuery.addEventListener('change', handleChange);
    
    // Cleanup
    return () => {
      mediaQuery.removeEventListener('change', handleChange);
    };
  }, [query]);

  return matches;
}

export default useMediaQuery;

Użycie:

function ResponsiveNav() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
  const isDesktop = useMediaQuery('(min-width: 1025px)');

  if (isMobile) return <MobileNav />;
  if (isTablet) return <TabletNav />;
  return <DesktopNav />;
}
useOnClickOutside – wykrywanie kliknięcia poza elementem

Przydatne dla dropdown, modal, tooltip:

import { useEffect, RefObject } from 'react';

function useOnClickOutside<T extends HTMLElement>(
  ref: RefObject<T>,
  handler: (event: MouseEvent | TouchEvent) => void
): void {
  useEffect(() => {
    function listener(event: MouseEvent | TouchEvent) {
      // Jeśli kliknięcie było wewnątrz elementu, nic nie rób
      if (!ref.current || ref.current.contains(event.target as Node)) {
        return;
      }
      
      // Kliknięcie było na zewnątrz – wywołaj handler
      handler(event);
    }

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}

export default useOnClickOutside;

Użycie:

function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  useOnClickOutside(dropdownRef, () => setIsOpen(false));

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        Menu
      </button>
      {isOpen && (
        <ul className="dropdown-menu">
          <li>Opcja 1</li>
          <li>Opcja 2</li>
          <li>Opcja 3</li>
        </ul>
      )}
    </div>
  );
}
useAsync – generyczny hook dla async operacji

Uogólnienie useFetch dla dowolnych async funkcji:

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

interface UseAsyncResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  execute: () => Promise<void>;
}

function useAsync<T>(
  asyncFunction: () => Promise<T>,
  immediate: boolean = true
): UseAsyncResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(immediate);
  const [error, setError] = useState<Error | null>(null);

  const execute = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const result = await asyncFunction();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setLoading(false);
    }
  }, [asyncFunction]);

  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

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

export default useAsync;

Użycie:

function UserProfile({ userId }: { userId: number }) {
  const { data: user, loading, error, execute } = useAsync(
    () => fetch(`/api/users/${userId}`).then(res => res.json())
  );

  if (loading) return <div>Ładowanie...</div>;
  if (error) return <div>Błąd: {error.message}</div>;

  return (
    <div>
      <h2>{user?.name}</h2>
      <button onClick={execute}>Odśwież</button>
    </div>
  );
}
usePrevious – poprzednia wartość

Czasem potrzebujesz porównać aktualną wartość z poprzednią:

import { useRef, useEffect } from 'react';

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

export default usePrevious;

Użycie:

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>Aktualny: {count}</p>
      <p>Poprzedni: {prevCount}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}
useForm – zarządzanie formularzem

Kompletny Hook do obsługi formularzy:

import { useState, ChangeEvent, FormEvent } from 'react';

interface UseFormResult<T> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
  handleBlur: (field: keyof T) => void;
  handleSubmit: (e: FormEvent) => void;
  resetForm: () => void;
  setFieldValue: (field: keyof T, value: any) => void;
}

function useForm<T extends Record<string, any>>(
  initialValues: T,
  onSubmit: (values: T) => void | Promise<void>,
  validate?: (values: T) => Partial<Record<keyof T, string>>
): UseFormResult<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>>>({});

  function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) {
    const { name, value, type } = e.target;
    const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;

    setValues(prev => ({
      ...prev,
      [name]: newValue
    }));

    // Waliduj pole
    if (validate) {
      const newErrors = validate({ ...values, [name]: newValue });
      setErrors(prev => ({
        ...prev,
        [name]: newErrors[name as keyof T]
      }));
    }
  }

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

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();

    // Oznacz wszystkie pola jako touched
    const allTouched = Object.keys(values).reduce((acc, key) => ({
      ...acc,
      [key]: true
    }), {} as Partial<Record<keyof T, boolean>>);
    setTouched(allTouched);

    // Waliduj
    if (validate) {
      const validationErrors = validate(values);
      setErrors(validationErrors);

      if (Object.keys(validationErrors).length > 0) {
        return;
      }
    }

    await onSubmit(values);
  }

  function resetForm() {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }

  function setFieldValue(field: keyof T, value: any) {
    setValues(prev => ({
      ...prev,
      [field]: value
    }));
  }

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm,
    setFieldValue
  };
}

export default useForm;

Użycie:

interface LoginFormValues {
  email: string;
  password: string;
}

function LoginForm() {
  const {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit
  } = useForm<LoginFormValues>(
    { email: '', password: '' },
    async (values) => {
      console.log('Logowanie:', values);
      // API call
    },
    (values) => {
      const errors: Partial<Record<keyof LoginFormValues, string>> = {};
      
      if (!values.email) {
        errors.email = 'Email jest wymagany';
      } else if (!/\S+@\S+\.\S+/.test(values.email)) {
        errors.email = 'Email jest nieprawidłowy';
      }
      
      if (!values.password) {
        errors.password = 'Hasło jest wymagane';
      } else if (values.password.length < 6) {
        errors.password = 'Hasło musi mieć co najmniej 6 znaków';
      }
      
      return errors;
    }
  );

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="email"
          type="email"
          value={values.email}
          onChange={handleChange}
          onBlur={() => handleBlur('email')}
          placeholder="Email"
        />
        {touched.email && errors.email && (
          <span className="error">{errors.email}</span>
        )}
      </div>

      <div>
        <input
          name="password"
          type="password"
          value={values.password}
          onChange={handleChange}
          onBlur={() => handleBlur('password')}
          placeholder="Hasło"
        />
        {touched.password && errors.password && (
          <span className="error">{errors.password}</span>
        )}
      </div>

      <button type="submit">Zaloguj</button>
    </form>
  );
}
Dobre praktyki tworzenia Custom Hooks

1. Nazwa zawsze zaczyna się od use

// ✅ Dobrze
function useWindowSize() { }
function useFetch() { }

// ❌ Źle
function windowSize() { }
function fetchData() { }

2. Jeden Hook = jedna odpowiedzialność

// ❌ Hook robi za dużo
function useEverything() {
  // fetch data
  // localStorage
  // window resize
  // form handling
}

// ✅ Osobne Hooki
function useFetch() { }
function useLocalStorage() { }
function useWindowSize() { }
function useForm() { }

3. Zwracaj obiekty dla wielu wartości, tablice dla 2-3 wartości

// ✅ Dobrze - 2 wartości, tablica
function useToggle(): [boolean, () => void] {
  // ...
  return [value, toggle];
}

// ✅ Dobrze - wiele wartości, obiekt
function useFetch<T>(): { data: T | null; loading: boolean; error: string | null } {
  // ...
  return { data, loading, error };
}

4. Typuj generics gdy Hook jest reużywalny

// ✅ Generic dla różnych typów danych
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  // ...
}

function useFetch<T>(url: string): { data: T | null; loading: boolean; error: string | null } {
  // ...
}

5. Dokumentuj złożone Hooki

/**
 * Hook do pobierania danych z API
 * 
 * @param url - URL do fetch
 * @returns Obiekt z data, loading, error, refetch
 * 
 * @example
 * ```tsx
 * const { data, loading, error } = useFetch<User[]>('/api/users');
 * ```
 */
function useFetch<T>(url: string): UseFetchResult<T> {
  // ...
}

6. Cleanup w useEffect

function useEventListener(event: string, handler: Function) {
  useEffect(() => {
    window.addEventListener(event, handler);
    
    // ✅ Zawsze cleanup!
    return () => {
      window.removeEventListener(event, handler);
    };
  }, [event, handler]);
}

7. Używaj useCallback dla funkcji zwracanych z Hooka

function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);

  // ✅ useCallback zapobiega tworzeniu nowej funkcji przy każdym renderze
  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount(prev => prev - 1);
  }, []);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  return { count, increment, decrement, reset };
}
Testowanie Custom Hooks

Custom Hooki są funkcjami – testujemy je osobno!

Setup testów

npm install --save-dev @testing-library/react @testing-library/react-hooks jest

Test useToggle

// useToggle.test.ts
import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';

describe('useToggle', () => {
  test('initial value is false by default', () => {
    const { result } = renderHook(() => useToggle());
    expect(result.current[0]).toBe(false);
  });

  test('initial value can be set', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current[0]).toBe(true);
  });

  test('toggle changes value', () => {
    const { result } = renderHook(() => useToggle(false));
    
    expect(result.current[0]).toBe(false);
    
    act(() => {
      result.current[1](); // toggle
    });
    
    expect(result.current[0]).toBe(true);
    
    act(() => {
      result.current[1](); // toggle again
    });
    
    expect(result.current[0]).toBe(false);
  });
});

Test useLocalStorage

// useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import useLocalStorage from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  test('reads initial value from localStorage', () => {
    localStorage.setItem('testKey', JSON.stringify('stored value'));
    
    const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
    
    expect(result.current[0]).toBe('stored value');
  });

  test('uses initial value when localStorage is empty', () => {
    const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
    
    expect(result.current[0]).toBe('default');
  });

  test('updates localStorage when value changes', () => {
    const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
    
    act(() => {
      result.current[1]('updated');
    });
    
    expect(localStorage.getItem('testKey')).toBe(JSON.stringify('updated'));
  });
});

Test useDebounce

// useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import useDebounce from './useDebounce';

jest.useFakeTimers();

describe('useDebounce', () => {
  test('returns initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('test', 500));
    expect(result.current).toBe('test');
  });

  test('debounces value changes', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    );

    expect(result.current).toBe('initial');

    // Zmień wartość
    rerender({ value: 'updated', delay: 500 });

    // Wartość jeszcze się nie zmieniła
    expect(result.current).toBe('initial');

    // Fast-forward time
    act(() => {
      jest.advanceTimersByTime(500);
    });

    // Teraz wartość jest zaktualizowana
    expect(result.current).toBe('updated');
  });
});
Kompozycja Custom Hooks

Możesz łączyć Custom Hooki w bardziej złożone:

// useAuthenticatedFetch.ts
function useAuthenticatedFetch<T>(url: string) {
  const { token } = useAuth(); // Custom hook dla auth
  const { data, loading, error } = useFetch<T>(url, {
    headers: {
      Authorization: `Bearer ${token}`
    }
  });

  return { data, loading, error };
}

// usePersistedState.ts
function usePersistedState<T>(key: string, initialValue: T) {
  const [value, setValue] = useLocalStorage(key, initialValue);
  const debouncedValue = useDebounce(value, 500);

  useEffect(() => {
    // Zapisz debounced value do localStorage
    localStorage.setItem(key, JSON.stringify(debouncedValue));
  }, [key, debouncedValue]);

  return [value, setValue] as const;
}
Biblioteki Custom Hooks

Nie musisz pisać wszystkiego od zera! Istnieją świetne biblioteki:

react-use

npm install react-use

Zawiera ponad 100 gotowych hooków:

  • useToggle, useBoolean
  • useLocalStorage, useSessionStorage
  • useAsync, useFetch
  • useDebounce, useThrottle
  • useWindowSize, useMediaQuery
  • i wiele więcej!
import { useToggle, useDebounce, useLocalStorage } from 'react-use';

function Component() {
  const [on, toggle] = useToggle(false);
  const [value, setValue] = useLocalStorage('key', 'default');
  // ...
}

usehooks-ts

npm install usehooks-ts

Biblioteka TypeScript-first z wysokiej jakości hookami:

  • useLocalStorage
  • useDebounce
  • useOnClickOutside
  • useMediaQuery
  • useEventListener

ahooks

Kompleksowa biblioteka z 100+ hookami, szczególnie popularna w aplikacjach enterprise.

Kiedy używać bibliotek vs własnych hooków?

  • Biblioteki: Dla standardowych przypadków (localStorage, debounce, media queries)
  • Własne Hooki: Dla biznesowej logiki specyficznej dla Twojej aplikacji
Zaawansowane wzorce

Hook z callback ref

Czasem potrzebujesz dostępu do DOM elementu:

function useMeasure() {
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const [node, setNode] = useState<HTMLElement | null>(null);

  const ref = useCallback((node: HTMLElement | null) => {
    setNode(node);
  }, []);

  useEffect(() => {
    if (!node) return;

    const measure = () => {
      setDimensions({
        width: node.offsetWidth,
        height: node.offsetHeight
      });
    };

    measure();

    const resizeObserver = new ResizeObserver(measure);
    resizeObserver.observe(node);

    return () => {
      resizeObserver.disconnect();
    };
  }, [node]);

  return [ref, dimensions] as const;
}

// Użycie
function Component() {
  const [ref, { width, height }] = useMeasure();

  return (
    <div ref={ref}>
      <p>Szerokość: {width}px</p>
      <p>Wysokość: {height}px</p>
    </div>
  );
}

Hook z konfiguracją

Opcje konfiguracyjne zwiększają elastyczność:

interface UseFetchOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  headers?: Record<string, string>;
  body?: any;
  enabled?: boolean;
}

function useFetch<T>(url: string, options: UseFetchOptions = {}) {
  const { method = 'GET', headers = {}, body, enabled = true } = options;
  
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!enabled) return;

    let cancelled = false;

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, {
          method,
          headers: {
            'Content-Type': 'application/json',
            ...headers
          },
          body: body ? JSON.stringify(body) : undefined
        });

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

        const result = await response.json();
        
        if (!cancelled) {
          setData(result);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : 'Error');
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url, method, JSON.stringify(headers), JSON.stringify(body), enabled]);

  return { data, loading, error };
}

// Użycie
function Component({ userId }: { userId: number }) {
  const { data, loading } = useFetch<User>(
    `/api/users/${userId}`,
    {
      method: 'GET',
      headers: { Authorization: 'Bearer token' },
      enabled: userId > 0 // Warunkowe włączenie
    }
  );

  // ...
}

Hook zwracający więcej metod

function useArray<T>(initialArray: T[] = []) {
  const [array, setArray] = useState(initialArray);

  const push = useCallback((element: T) => {
    setArray(prev => [...prev, element]);
  }, []);

  const remove = useCallback((index: number) => {
    setArray(prev => prev.filter((_, i) => i !== index));
  }, []);

  const update = useCallback((index: number, element: T) => {
    setArray(prev => prev.map((item, i) => i === index ? element : item));
  }, []);

  const clear = useCallback(() => {
    setArray([]);
  }, []);

  const filter = useCallback((callback: (item: T) => boolean) => {
    setArray(prev => prev.filter(callback));
  }, []);

  return {
    array,
    set: setArray,
    push,
    remove,
    update,
    clear,
    filter
  };
}

// Użycie
function TodoList() {
  const todos = useArray<Todo>([]);

  return (
    <div>
      <button onClick={() => todos.push({ id: Date.now(), text: 'New Todo' })}>
        Dodaj
      </button>
      {todos.array.map((todo, index) => (
        <div key={todo.id}>
          {todo.text}
          <button onClick={() => todos.remove(index)}>Usuń</button>
        </div>
      ))}
    </div>
  );
}
Praktyczny przykład: useCart

Połączmy wszystko w kompletny, produkcyjny Hook:

// useCart.ts
import { useState, useEffect, useCallback } from 'react';

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

interface CartItem extends Product {
  quantity: number;
}

interface UseCartResult {
  items: CartItem[];
  addItem: (product: Product, quantity?: number) => void;
  removeItem: (productId: number) => void;
  updateQuantity: (productId: number, quantity: number) => void;
  clearCart: () => void;
  total: number;
  itemCount: number;
}

function useCart(): UseCartResult {
  // Inicjalizacja z localStorage
  const [items, setItems] = useState<CartItem[]>(() => {
    try {
      const saved = localStorage.getItem('cart');
      return saved ? JSON.parse(saved) : [];
    } catch {
      return [];
    }
  });

  // Synchronizacja z localStorage
  useEffect(() => {
    try {
      localStorage.setItem('cart', JSON.stringify(items));
    } catch (error) {
      console.error('Error saving cart:', error);
    }
  }, [items]);

  const addItem = useCallback((product: Product, quantity: number = 1) => {
    setItems(currentItems => {
      const existingItem = currentItems.find(item => item.id === product.id);

      if (existingItem) {
        return currentItems.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + quantity }
            : item
        );
      }

      return [...currentItems, { ...product, quantity }];
    });
  }, []);

  const removeItem = useCallback((productId: number) => {
    setItems(currentItems => currentItems.filter(item => item.id !== productId));
  }, []);

  const updateQuantity = useCallback((productId: number, quantity: number) => {
    if (quantity <= 0) {
      removeItem(productId);
      return;
    }

    setItems(currentItems =>
      currentItems.map(item =>
        item.id === productId ? { ...item, quantity } : item
      )
    );
  }, [removeItem]);

  const clearCart = useCallback(() => {
    setItems([]);
  }, []);

  // Computed values
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);

  return {
    items,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    total,
    itemCount
  };
}

export default useCart;

Użycie:

// CartContext.tsx - Łączenie z Context
import { createContext, useContext, ReactNode } from 'react';
import useCart, { UseCartResult } from './useCart';

const CartContext = createContext<UseCartResult | undefined>(undefined);

export function CartProvider({ children }: { children: ReactNode }) {
  const cart = useCart();

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

export function useCartContext() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCartContext must be used within CartProvider');
  }
  return context;
}

// ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
  const { addItem } = useCartContext();

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price} PLN</p>
      <button onClick={() => addItem(product)}>
        Dodaj do koszyka
      </button>
    </div>
  );
}

// Cart.tsx
function Cart() {
  const { items, removeItem, updateQuantity, total, itemCount } = useCartContext();

  return (
    <div className="cart">
      <h2>Koszyk ({itemCount} przedmiotów)</h2>
      {items.map(item => (
        <div key={item.id} className="cart-item">
          <span>{item.name}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
            min="1"
          />
          <span>{(item.price * item.quantity).toFixed(2)} PLN</span>
          <button onClick={() => removeItem(item.id)}>Usuń</button>
        </div>
      ))}
      <div className="cart-total">
        <strong>Suma: {total.toFixed(2)} PLN</strong>
      </div>
    </div>
  );
}

Ten Hook ma wszystko:

  • TypeScript type safety
  • localStorage persistence
  • Computed values (total, itemCount)
  • useCallback dla optymalizacji
  • Immutable updates
  • Gotowy do użycia z Context
Podsumowanie

To był obszerny wpis pełen praktycznej wiedzy! Nauczyliśmy się:

  • Czym są Custom Hooks – funkcje wyciągające reużywalną logikę
  • Rules of Hooks – zasady których musimy przestrzegać
  • Praktyczne HookiuseToggle, useLocalStorage, useFetch, useDebounce, useWindowSize, useMediaQuery, useOnClickOutside, useAsync, usePrevious, useForm
  • Dobre praktyki – naming, single responsibility, typowanie, dokumentacja, cleanup
  • Testowanie – jak testować Custom Hooks
  • Kompozycja – łączenie hooków w bardziej złożone
  • Biblioteki – react-use, usehooks-ts, ahooks
  • Zaawansowane wzorce – callback ref, konfiguracja, bogate API
  • Produkcyjny przykładuseCart z pełną funkcjonalnością

Custom Hooks to superpower React – pozwalają wyciągnąć każdą logikę z komponentów i użyć jej w wielu miejscach. Twój kod staje się czystszy, bardziej modularny i łatwiejszy w utrzymaniu!

W kolejnym wpisie poznamy React Router – nauczymy się tworzyć wielostronicowe aplikacje z nawigacją, parametrami URL, chronionymi trasami!

Zadanie dla Ciebie

Stwórz następujące Custom Hooks:

  1. useCounter – counter z increment, decrement, reset, setValue
  2. useThrottle – throttling wartości (podobny do debounce ale limituje częstotliwość)
  3. useCopyToClipboard – kopiowanie do schowka z feedback
  4. useInterval – setInterval w Hooku (z cleanup)
  5. (Bonus) useInfiniteScroll – infinite scroll dla list

Przykładowe rozwiązania znajdziesz w repozytorium na GitHub (link w następnym wpisie).