Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz
2026-02-03
Wprowadzenie
W poprzednich wpisach poznaliśmy komponenty, props , state i zdarzenia. Stworzyliśmy interaktywną aplikację Todo, która reaguje na działania użytkownika i zarządza swoim stanem. Jednak nasze komponenty wciąż operują wyłącznie na danych, które sami w nich zdefiniowaliśmy.
W prawdziwych aplikacjach komponenty muszą wykonywać efekty uboczne (side effects): pobierać dane z API, subskrybować zdarzenia, ustawiać timery, manipulować DOM bezpośrednio, zapisywać dane do localStorage . Do tego służy useEffect Hook – jeden z najważniejszych i jednocześnie najtrudniejszych do opanowania Hooków w React .
W tym wpisie nauczymy się jak działa cykl życia komponentów w React , poznamy useEffect od podstaw, zrozumiemy dependency array i nauczymy się czyszczenia efektów (cleanup). Będzie dużo praktycznych przykładów i pułapek, których należy unikać. Przygotuj się na wyzwanie – useEffect wymaga zrozumienia, ale gdy go opanujesz, otworzą się przed Tobą wszystkie możliwości React !
Cykl życia komponentu
W klasowych komponentach React mieliśmy metody lifecycle: componentDidMount , componentDidUpdate , componentWillUnmount . W komponentach funkcyjnych z Hooks mamy useEffect , który łączy w sobie wszystkie te fazy.
Każdy komponent React przechodzi przez trzy fazy:
Mounting – komponent jest tworzony i dodawany do DOM
Updating – komponent re-renderuje się gdy zmienia się state lub props
Unmounting – komponent jest usuwany z DOM
┌─────────┐
│ Mounting│
└────┬────┘
│
▼
┌─────────┐
│Updating │ ◄───┐
└────┬────┘ │
│ │
└─────────┘
│
▼
┌───────────┐
│Unmounting │
└───────────┘
useEffect pozwala nam "zahakować się" do każdej z tych faz.
useEffect Hook – podstawy
import { useEffect } from 'react';
useEffect(() => {
// Kod do wykonania
});
useEffect przyjmuje funkcję, która zostanie wykonana po renderowaniu komponentu.
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Komponent się wyrenderował!');
});
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
Co się dzieje?
Przy każdym renderze (początkowym i każdej aktualizacji) wyświetli się log w konsoli. Kliknij przycisk kilka razy – zobaczysz logi po każdym kliknięciu.
Problem: To zazwyczaj nie jest to, czego chcemy! Nie chcemy uruchamiać efektu przy każdym renderze.
Dependency Array
Drugi argument useEffect to dependency array – tablica zależności:
useEffect(() => {
// Efekt
}, [dependencies]);
Dependency array kontroluje kiedy efekt się uruchamia:
useEffect(() => {
console.log('Każdy render!');
});
Uruchamia się przy każdym renderze. Rzadko tego chcemy.
useEffect(() => {
console.log('Tylko przy montowaniu!');
}, []);
Uruchamia się tylko raz , gdy komponent jest montowany. To odpowiednik componentDidMount .
useEffect(() => {
console.log('Count zmienił się!', count);
}, [count]);
Uruchamia się przy montowaniu i za każdym razem gdy count się zmieni .
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Kliknięto ${count} razy`;
}, [count]);
return (
<div>
<p>Kliknięto {count} razy</p>
<button onClick={() => setCount(count + 1)}>Kliknij</button>
</div>
);
}
Tytuł karty przeglądarki aktualizuje się przy każdej zmianie count !
KRYTYCZNE: Musisz umieścić w dependency array wszystkie wartości z komponentu, których używasz w efekcie (state , props , zmienne).
// ❌ ŹLE - brakuje zależności
function Component({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // Używamy userId
}, []); // Ale nie ma go w dependencies!
}
// ✅ DOBRZE
function Component({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // userId jest w dependencies
}
Dlaczego to ważne? Jeśli userId się zmieni, efekt nie wykona się ponownie i pobierzemy dane dla starego użytkownika!
React ma ESLint plugin (eslint-plugin-react-hooks ), który ostrzega o brakujących zależnościach. Zawsze go słuchaj!
Cleanup function – sprzątanie po efektach
Niektóre efekty wymagają "sprzątania" – anulowania subskrypcji, czyszczenia timerów, itp. Do tego służy cleanup function .
useEffect(() => {
// Setup
return () => {
// Cleanup
};
}, [dependencies]);
Funkcja zwracana z useEffect jest wywoływana:
Przed kolejnym uruchomieniem efektu (gdy dependencies się zmienią)
Gdy komponent jest unmountowany
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup - wyczyść interval gdy komponent jest unmountowany
return () => {
clearInterval(intervalId);
};
}, []); // Pusta tablica - uruchom raz
return <div>Upłynęło: {seconds} sekund</div>;
}
Co by się stało bez cleanup? Interval dalej działałby nawet gdy komponent jest usunięty, powodując memory leak!
import { useState, useEffect } from 'react';
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
// Cleanup - usuń listener
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>Szerokość okna: {width}px</div>;
}
Zmień rozmiar okna przeglądarki – szerokość się aktualizuje!
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Sprawdź czy komponent jest wciąż zamontowany
if (!cancelled) {
setUser(data);
}
} catch (error) {
if (!cancelled) {
console.error('Error fetching user:', error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
// Cleanup
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <div>Ładowanie...</div>;
if (!user) return <div>Nie znaleziono użytkownika</div>;
return (
<div>
<h2>{user.name}</h2>
</div>
);
}
Dlaczego cancelled flag? Jeśli komponent zostanie unmountowany przed zakończeniem fetch, nie chcemy ustawiać state na nieistniejącym komponencie.
Nowoczesna alternatywa z AbortController :
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Error:', error);
}
}
}
fetchUser();
return () => {
controller.abort();
};
}, [userId]);
TypeScript w useEffect
useEffect sam w sobie nie wymaga specjalnego typowania – TypeScript wywnioskuje typy z kontekstu:
const [count, setCount] = useState<number>(0);
useEffect(() => {
// TypeScript wie, że count to number
console.log(count.toFixed(2));
}, [count]);
Ale jeśli definiujesz funkcje wewnątrz useEffect , możesz je typować:
useEffect(() => {
async function fetchData(): Promise<void> {
const response = await fetch('/api/data');
const data: DataType = await response.json();
setData(data);
}
fetchData();
}, []);
Częste pułapki i błędy
// ❌ NIESKOŃCZONA PĘTLA!
function Component() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData); // Ustawia state
}); // Brak dependency array!
// setData -> render -> useEffect -> setData -> render -> useEffect -> ...
}
Rozwiązanie: Dodaj pustą dependency array:
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData);
}, []); // ✅ Wykona się tylko raz
// ❌ Problem
function Component() {
const config = { limit: 10 }; // Nowy obiekt przy każdym renderze!
useEffect(() => {
fetchData(config);
}, [config]); // config zawsze jest "nowy", więc efekt uruchamia się ciągle
}
Rozwiązanie 1: Przenieś config do useEffect
useEffect(() => {
const config = { limit: 10 };
fetchData(config);
}, []);
Rozwiązanie 2: Użyj useMemo (o tym w kolejnych wpisach)
const config = useMemo(() => ({ limit: 10 }), []);
// ❌ Problem
function Component() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(count + 1); // count to zawsze 0!
}, 1000);
return () => clearInterval(intervalId);
}, []); // Pusta dependency - efekt uruchamia się raz
}
count w closure to zawsze początkowa wartość (0). Licznik zwiększy się do 1 i zatrzyma.
Rozwiązanie: Functional update
useEffect(() => {
const intervalId = setInterval(() => {
setCount(prev => prev + 1); // ✅ Zawsze aktualna wartość
}, 1000);
return () => clearInterval(intervalId);
}, []);
// ❌ To nie zadziała
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
useEffect nie może być async, bo musi zwracać void lub cleanup function, nie Promise!
Rozwiązanie: Definiuj async function wewnątrz
useEffect(() => {
async function loadData() {
const data = await fetchData();
setData(data);
}
loadData();
}, []);
Praktyczne przykłady
import { useState, useEffect } from 'react';
function PersistentCounter() {
const [count, setCount] = useState(() => {
// Lazy initialization - wykonuje się tylko raz
const saved = localStorage.getItem('count');
return saved ? parseInt(saved, 10) : 0;
});
// Zapisz do localStorage przy każdej zmianie
useEffect(() => {
localStorage.setItem('count', count.toString());
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Odśwież stronę – licznik zachowuje wartość!
import { useState, useEffect } from 'react';
function SearchUsers() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Nie szukaj jeśli query jest pusty
if (!query) {
setResults([]);
return;
}
setLoading(true);
// Debounce - czekaj 500ms po ostatniej zmianie
const timeoutId = setTimeout(() => {
// Symulacja API call
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
});
}, 500);
// Cleanup - anuluj poprzedni timeout
return () => {
clearTimeout(timeoutId);
};
}, [query]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Szukaj użytkowników..."
/>
{loading && <p>Szukam...</p>}
<ul>
{results.map((result, index) => (
<li key={index}>{result}</li>
))}
</ul>
</div>
);
}
Wpisywanie w input nie wywołuje API przy każdej literze – czeka 500ms po zaprzestaniu pisania!
import { useState, useEffect } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
function Posts() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchPosts() {
try {
setLoading(true);
setError(null);
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setPosts(data.slice(0, 10)); // Tylko pierwsze 10
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Wystąpił błąd');
setLoading(false);
}
}
}
fetchPosts();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return <div>Ładowanie postów...</div>;
}
if (error) {
return <div>Błąd: {error}</div>;
}
return (
<div>
<h1>Posty</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
</div>
);
}
export default Posts;
Kompletna obsługa: loading state, error handling, cleanup.
import { useState, useEffect } from 'react';
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const intervalId = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(intervalId);
}, []);
return (
<div>
<h2>Aktualny czas:</h2>
<p>{time.toLocaleTimeString('pl-PL')}</p>
</div>
);
}
export default Clock;
Zegar aktualizuje się co sekundę!
import { useState, useEffect } from 'react';
function OnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Cleanup - usuń oba listenery
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return (
<div style={{
padding: '10px',
backgroundColor: isOnline ? '#4CAF50' : '#f44336',
color: 'white'
}}>
{isOnline ? '🟢 Online' : '🔴 Offline'}
</div>
);
}
export default OnlineStatus;
Spróbuj wyłączyć internet – status się zmieni!
Wiele useEffect w komponencie
Możesz mieć wiele useEffect w jednym komponencie. To nawet zalecane – separation of concerns :
function UserDashboard({ userId }: { userId: number }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [notifications, setNotifications] = useState([]);
// Efekt 1: Pobierz dane użytkownika
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// Efekt 2: Pobierz posty użytkownika
useEffect(() => {
fetchUserPosts(userId).then(setPosts);
}, [userId]);
// Efekt 3: Subskrybuj notyfikacje
useEffect(() => {
const unsubscribe = subscribeToNotifications(userId, setNotifications);
return unsubscribe;
}, [userId]);
// Efekt 4: Aktualizuj document title
useEffect(() => {
if (user) {
document.title = `Dashboard - ${user.name}`;
}
}, [user]);
// ...
}
Każdy efekt ma swoją odpowiedzialność – łatwiej czytać i testować.
useEffect vs useLayoutEffect
Jest jeszcze jeden Hook podobny do useEffect : useLayoutEffect . Różnica:
useEffect – uruchamia się po tym jak przeglądarka wyrenderuje zmiany (asynchronicznie)
useLayoutEffect – uruchamia się przed tym jak przeglądarka wyrenderuje zmiany (synchronicznie)
Render -> useLayoutEffect -> Browser Paint -> useEffect
Używaj useLayoutEffect tylko gdy:
Musisz zmierzyć DOM (np. pozycję elementu)
Musisz zmienić DOM przed wyrenderowaniem (uniknięcie flickera) — czyli krótkiego, niepożądanego "mignięcia" interfejsu, które pojawia się, gdy przeglądarka na moment pokazuje stary lub nieostylowany stan elementów, zanim React zdąży je zaktualizować.
W 99% przypadków używaj useEffect . useLayoutEffect blokuje rendering i może spowolnić aplikację.
import { useLayoutEffect, useRef } from 'react';
function MeasureElement() {
const divRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (divRef.current) {
const { width, height } = divRef.current.getBoundingClientRect();
console.log('Wymiary:', width, height);
}
}, []);
return <div ref={divRef}>Mierz mnie!</div>;
}
Best Practices dla useEffect
// ❌ Źle - jeden efekt robi za dużo
useEffect(() => {
fetchUser();
subscribeToNotifications();
updateDocumentTitle();
}, [userId]);
// ✅ Dobrze - osobne efekty
useEffect(() => {
fetchUser();
}, [userId]);
useEffect(() => {
subscribeToNotifications();
}, [userId]);
useEffect(() => {
updateDocumentTitle();
}, [userId]);
useEffect(() => {
const timer = setInterval(() => {}, 1000);
return () => clearInterval(timer); // ✅ Cleanup
}, []);
useEffect(() => {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler); // ✅ Cleanup
}, []);
// ❌ Problem - nowy obiekt przy każdym renderze
function Component() {
const options = { limit: 10 };
useEffect(() => {
fetchData(options);
}, [options]); // options zawsze jest "nowy"
}
// ✅ Rozwiązanie 1 - przenieś do efektu
useEffect(() => {
const options = { limit: 10 };
fetchData(options);
}, []);
// ✅ Rozwiązanie 2 - zależność od wartości, nie obiektu
const limit = 10;
useEffect(() => {
fetchData({ limit });
}, [limit]);
useEffect(() => {
async function fetchAndSetUserData() {
const data = await fetchUser(userId);
setUser(data);
}
fetchAndSetUserData();
}, [userId]);
useEffect(() => {
// Synchronizuj scroll position z localStorage
// aby zachować pozycję po odświeżeniu strony
const savedPosition = localStorage.getItem('scrollY');
if (savedPosition) {
window.scrollTo(0, parseInt(savedPosition, 10));
}
function handleScroll() {
localStorage.setItem('scrollY', window.scrollY.toString());
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
Kompletny przykład: User Profile z fetch
Połączmy wszystko czego się nauczyliśmy w kompletny komponent:
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
email: string;
phone: string;
website: string;
}
interface UserProfileProps {
userId: number;
}
function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
try {
setLoading(true);
setError(null);
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (!response.ok) {
throw new Error('Nie udało się pobrać danych użytkownika');
}
const data: User = await response.json();
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Nieznany błąd');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
return () => {
cancelled = true;
};
}, [userId]);
// Aktualizuj document title gdy user się zmieni
useEffect(() => {
if (user) {
document.title = `Profil - ${user.name}`;
}
// Cleanup - przywróć domyślny tytuł
return () => {
document.title = 'React App';
};
}, [user]);
if (loading) {
return (
<div className="user-profile loading">
<p>Ładowanie profilu...</p>
</div>
);
}
if (error) {
return (
<div className="user-profile error">
<p>Błąd: {error}</p>
<button onClick={() => window.location.reload()}>
Spróbuj ponownie
</button>
</div>
);
}
if (!user) {
return (
<div className="user-profile">
<p>Nie znaleziono użytkownika</p>
</div>
);
}
return (
<div className="user-profile">
<h2>{user.name}</h2>
<div className="user-details">
<p><strong>Email:</strong> {user.email}</p>
<p><strong>Telefon:</strong> {user.phone}</p>
<p><strong>Strona:</strong> <a href={`https://${user.website}`}>{user.website}</a></p>
</div>
</div>
);
}
// Komponent który pozwala zmienić userId
function App() {
const [userId, setUserId] = useState(1);
return (
<div className="app">
<h1>Profile Viewer</h1>
<div className="controls">
<button onClick={() => setUserId(Math.max(1, userId - 1))}>
Poprzedni
</button>
<span>User ID: {userId}</span>
<button onClick={() => setUserId(Math.min(10, userId + 1))}>
Następny
</button>
</div>
<UserProfile userId={userId} />
</div>
);
}
export default App;
Dodaj style (App.css ):
.app {
max-width: 600px;
margin: 50px auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.controls {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin: 20px 0;
}
.controls button {
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.controls button:hover {
background-color: #2980b9;
}
.controls button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.user-profile {
margin-top: 30px;
padding: 20px;
border: 2px solid #3498db;
border-radius: 8px;
background-color: #f8f9fa;
}
.user-profile h2 {
margin-top: 0;
color: #2c3e50;
}
.user-details p {
margin: 10px 0;
}
.user-profile.loading {
text-align: center;
color: #7f8c8d;
}
.user-profile.error {
border-color: #e74c3c;
background-color: #fadbd8;
text-align: center;
}
.user-profile.error button {
margin-top: 10px;
padding: 8px 16px;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.user-profile.error button:hover {
background-color: #c0392b;
}
To pełna, działająca aplikacja! Kliknij "Następny" i "Poprzedni" – zobaczysz jak useEffect reaguje na zmianę userId i pobiera nowe dane.
Co tu się dzieje?
Pierwszy useEffect pobiera dane użytkownika gdy userId się zmienia
Drugi useEffect aktualizuje tytuł karty gdy mamy dane użytkownika
Cleanup functions zapewniają, że nie ustawiamy state na unmountowanym komponencie
Pełna obsługa stanów: loading, error, success
TypeScript gwarantuje type safety
Podsumowanie
To był intensywny wpis! Nauczyliśmy się:
✅ Cykl życia komponentu – mounting, updating, unmounting
✅ useEffect Hook – wykonywanie efektów ubocznych
✅ Dependency array – kontrolowanie kiedy efekt się uruchamia
✅ Cleanup function – sprzątanie po efektach
✅ TypeScript w useEffect – typowanie async funkcji
✅ Częste pułapki – nieskończone pętle, stale dependencies, closure problems
✅ Praktyczne przykłady – localStorage , debouncing, fetch data, timery, event listeners
✅ Best practices – separation of concerns, właściwe dependencies
✅ Kompletna aplikacja – User Profile z pełną obsługą fetch
useEffect to jeden z najtrudniejszych Hooków do opanowania, ale teraz masz solidne podstawy. W kolejnym wpisie poznamy useContext – sposób na przekazywanie danych przez wiele poziomów komponentów bez prop drilling, oraz nauczymy się tworzyć custom hooks do reużywalnej logiki.
Rozszerz naszą aplikację Todo z poprzedniego wpisu:
Zapisuj todos do localStorage używając useEffect
Dodaj timer pokazujący ile czasu minęło od ostatniej zmiany
Dodaj licznik aktywnych zadań, który aktualizuje document title
(Bonus) Dodaj autosave – zapisuj do localStorage z debounce 1 sekundy