Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz
2026-02-03
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:
Zaczyna się od use (konwencja, ale wymagana!)
Może używać innych Hooków (useState , useEffect , useContext , innych custom hooks)
Zwraca dane i/lub funkcje (dowolny format)
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:
Wywołuj Hooki tylko na top level – nie w ifach, pętlach, zagnieżdżonych funkcjach
Wywołuj Hooki tylko w funkcjach React – komponenty lub custom hooks
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
// ✅ Dobrze
function useWindowSize() { }
function useFetch() { }
// ❌ Źle
function windowSize() { }
function fetchData() { }
// ❌ Hook robi za dużo
function useEverything() {
// fetch data
// localStorage
// window resize
// form handling
}
// ✅ Osobne Hooki
function useFetch() { }
function useLocalStorage() { }
function useWindowSize() { }
function useForm() { }
// ✅ 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 };
}
// ✅ 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 } {
// ...
}
/**
* 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> {
// ...
}
function useEventListener(event: string, handler: Function) {
useEffect(() => {
window.addEventListener(event, handler);
// ✅ Zawsze cleanup!
return () => {
window.removeEventListener(event, handler);
};
}, [event, handler]);
}
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!
npm install --save-dev @testing-library/react @testing-library/react-hooks jest
// 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);
});
});
// 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'));
});
});
// 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:
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');
// ...
}
npm install usehooks-ts
Biblioteka TypeScript -first z wysokiej jakości hookami:
useLocalStorage
useDebounce
useOnClickOutside
useMediaQuery
useEventListener
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
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>
);
}
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
}
);
// ...
}
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 Hooki – useToggle , 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ład – useCart 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!
Stwórz następujące Custom Hooks:
useCounter – counter z increment, decrement, reset, setValue
useThrottle – throttling wartości (podobny do debounce ale limituje częstotliwość)
useCopyToClipboard – kopiowanie do schowka z feedback
useInterval – setInterval w Hooku (z cleanup)
(Bonus) useInfiniteScroll – infinite scroll dla list
Przykładowe rozwiązania znajdziesz w repozytorium na GitHub (link w następnym wpisie).