Kurs JavaScript i React · Moduł 15: Wzorce i architektura
Container/Presentational Pattern - Separacja logiki od widoku
W tej lekcji6
Lista załogi na mostku pobiera dane z API, pamięta zaznaczoną osobę, pokazuje spinner i rysuje karty, a wszystko to w jednym komponencie. Gdy Komandor Nova prosi o tę samą listę w oknie modalnym, ale z innym źródłem danych, trzeba skopiować cały komponent razem z pobieraniem. W Kosmicznej Stacji każdy system ma dwie warstwy: logikę sterowania (centrum dowodzenia) i wyświetlacz (panele informacyjne). W React stosujemy analogiczny podział: Container/Presentational pattern.
Idea wzorca
Wzorzec dzieli komponenty na dwa typy:
- Container (Smart Component): zarządza stanem, pobiera dane, obsługuje logikę biznesową. Nie ma własnego markupu ani stylów, renderuje tylko komponenty prezentacyjne i przekazuje im dane.
- Presentational (Dumb Component): odpowiada wyłącznie za renderowanie UI. Otrzymuje dane przez props i nie wie, skąd pochodzą.
Wzorzec spopularyzował Dan Abramov w 2015 roku. W 2019 dopisał do swojego artykułu, że nie zaleca już dzielenia komponentów w ten sposób na siłę, bo hooki pozwalają oddzielić logikę bez sztucznego podziału. Sama idea pozostaje jednak jedną z najważniejszych w React, więc zobaczmy obie warstwy.
Presentational Component
Zaczynamy od wyświetlacza. Komponent prezentacyjny jest prosty: przyjmuje dane i funkcję do obsługi kliknięcia, a sam niczego nie pobiera:
1// CrewList.jsx: komponent prezentacyjny
2function CrewList({ members, onSelect, selectedId }) {
3 return (
4 <ul className="crew-list">
5 {members.map(member => (
6 <li
7 key={member.id}
8 className={member.id === selectedId ? 'active' : ''}
9 onClick={() => onSelect(member.id)}
10 >
11 <span className="name">{member.name}</span>
12 <span className="role">{member.role}</span>
13 </li>
14 ))}
15 </ul>
16 );
17}CrewList nie wie, czy dane przyszły z API, z testu czy z localStorage. Nie zmienia też sam zaznaczenia: woła tylko onSelect i zostawia decyzję rodzicowi.
Cechy komponentu prezentacyjnego:
- Nie posiada własnego stanu (lub tylko stan UI, jak hover)
- Otrzymuje dane i callbacki przez props
- Łatwy do testowania: wystarczy przekazać props
- Wielokrotnego użytku w różnych kontekstach
Container Component
Teraz centrum dowodzenia. Kontener zarządza stanem i logiką: trzyma listę załogi, flagę ładowania i identyfikator zaznaczonej osoby, a dane pobiera po zamontowaniu:
1// CrewContainer.jsx: komponent kontenerowy
2function CrewContainer() {
3 const [members, setMembers] = useState([]);
4 const [selectedId, setSelectedId] = useState(null);
5 const [loading, setLoading] = useState(true);
6
7 useEffect(() => {
8 fetch('/api/crew')
9 .then(res => res.json())
10 .then(data => {
11 setMembers(data);
12 setLoading(false);
13 });
14 }, []);Druga połowa kontenera obsługuje wybór członka załogi i przekazuje wszystko w dół przez propsy, do komponentu prezentacyjnego:
1 const handleSelect = (id) => {
2 setSelectedId(id);
3 };
4
5 if (loading) return <Spinner />;
6
7 return (
8 <CrewList
9 members={members}
10 onSelect={handleSelect}
11 selectedId={selectedId}
12 />
13 );
14}Zwróć uwagę, czego tu nie ma: ani jednej klasy CSS, ani jednego <li>. Kontener renderuje tylko Spinner i CrewList. Gdy projektant przebuduje listę w siatkę kart, kontener się nie zmieni.
Ewolucja wzorca - Custom Hooks
Współcześnie zamiast komponentów kontenerowych częściej piszemy custom hooki. Custom hook to funkcja, której nazwa zaczyna się od use i która wywołuje inne hooki. Przenosimy do niej całą logikę kontenera:
1// useCrew.js: hook zamiast komponentu kontenerowego
2function useCrew() {
3 const [members, setMembers] = useState([]);
4 const [selectedId, setSelectedId] = useState(null);
5 const [loading, setLoading] = useState(true);
6
7 useEffect(() => {
8 fetch('/api/crew')
9 .then(res => res.json())
10 .then(data => {
11 setMembers(data);
12 setLoading(false);
13 });
14 }, []);
15
16 const selectMember = (id) => setSelectedId(id);
17
18 return { members, selectedId, selectMember, loading };
19}Hook zwraca obiekt z danymi i akcjami, ale nie zwraca JSX. Dlatego komponent, który go używa, zostaje krótki i czytelny:
1// Użycie: logika w hooku, UI w komponencie
2function CrewPage() {
3 const { members, selectedId, selectMember, loading } = useCrew();
4
5 if (loading) return <Spinner />;
6
7 return <CrewList members={members} onSelect={selectMember} selectedId={selectedId} />;
8}Logika jest ta sama co w CrewContainer, zmieniło się tylko miejsce: zamiast osobnego komponentu mamy funkcję, którą wywołasz w dowolnym komponencie. Hook może też zwracać wartości wyliczone, na przykład selectedMember znalezione przez members.find(). Dokumentacja React radzi liczyć takie dane podczas renderowania zamiast trzymać je w osobnym stanie, bo wtedy nie rozjadą się z oryginałem. Warstwy układają się od dołu: dane z API, hook z logiką i stanem, komponent kontenerowy jako łącznik (tu CrewPage), komponent prezentacyjny i wyrenderowany UI.
Korzyści z separacji
Separacja logiki od widoku daje kilka zalet, które są nieocenione w dużych projektach.
1. Łatwe testowanie
Komponenty prezentacyjne można testować bez mockowania API. Wystarczy podać im props i sprawdzić, co pojawiło się na ekranie:
1// Test komponentu prezentacyjnego: zero mocków!
2test('wyświetla listę załogi', () => {
3 const members = [
4 { id: 1, name: 'Anna', role: 'Pilotka' },
5 { id: 2, name: 'Jan', role: 'Inżynier' },
6 ];
7
8 render(<CrewList members={members} onSelect={() => {}} selectedId={null} />);
9
10 expect(screen.getByText('Anna')).toBeInTheDocument();
11 expect(screen.getByText('Inżynier')).toBeInTheDocument();
12});Logikę testujesz osobno. renderHook z React Testing Library uruchamia hook bez żadnego widoku, a waitFor czeka, aż pojawią się dane:
1// Test hooka: testujemy logikę osobno
2test('useCrew pobiera i zwraca załogę', async () => {
3 const { result } = renderHook(() => useCrew());
4 await waitFor(() => expect(result.current.members).toHaveLength(5));
5});Ten drugi test nadal potrzebuje podstawionego fetch, na przykład przez jest.fn() albo bibliotekę MSW, bo hook naprawdę pobiera dane. Tutaj zamockowane API zwraca pięć osób. Bez mocków obywa się tylko test komponentu prezentacyjnego.
2. Wielokrotne użycie
Komponent CrewList można używać w różnych kontekstach: na dashboardzie, w modalu, w panelu bocznym, za każdym razem z innym źródłem danych.
3. Niezależny rozwój
Projektant może pracować nad komponentem prezentacyjnym (wygląd, animacje), a programista nad hookiem (logika, API) równocześnie i bez konfliktów.
Kiedy stosować ten wzorzec?
- Tak, gdy komponent pobiera dane z API i je wyświetla: logika trafia do hooka, widok do komponentu prezentacyjnego
- Tak, gdy ta sama logika jest używana w wielu miejscach
- Nie dla prostych komponentów z minimalną logiką: nadmierna abstrakcja to też problem
Moja rada: nie twórz pliku CrewContainer.jsx z przyzwyczajenia. Zacznij od jednego komponentu, a gdy logika urośnie albo te same dane będą potrzebne drugiemu ekranowi, wyciągnij ją do hooka. Sama idea tego wzorca to fundament dobrej architektury React. Rozdzielenie "co wyświetlić" od "skąd wziąć dane" sprawia, że kod jest bardziej przewidywalny, testowalny i elastyczny, jak oddzielne systemy nawigacji i wyświetlania na statku kosmicznym. W następnej lekcji pójdziemy dalej i zbudujemy komponenty headless, które oddają cały wygląd konsumentowi.
Pamiętaj: centrum dowodzenia zdobywa dane, a panel tylko je pokazuje.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Role zostają w danych po angielsku (z nich powstają klasy CSS role-...), a na ekranie widać polskie nazwy
4const ROLE_LABELS = { captain: 'Kapitan', medic: 'Medyk', engineer: 'Inżynier', navigator: 'Nawigator', scientist: 'Naukowiec' };
5
6// === KOMPONENT PREZENTACYJNY ===
7// Samo wyświetlanie: bez pobierania danych i bez logiki biznesowej
8function CrewList({ members, selectedId, onSelect }) {
9 return (
10 <div className="crew-list">
11 {members.map(member => (
12 <div
13 key={member.id}
14 className={`crew-card ${member.id === selectedId ? 'selected' : ''}`}
15 onClick={() => onSelect(member.id)}
16 >
17 <div className="avatar">{member.name[0]}</div>
18 <div className="info">
19 <h4>{member.name}</h4>
20 <span className={`role role-${member.role}`}>{ROLE_LABELS[member.role]}</span>
21 </div>
22 <span className={`status ${member.active ? 'active' : 'inactive'}`}>
23 {member.active ? 'Na służbie' : 'Poza służbą'}
24 </span>
25 </div>
26 ))}
27 </div>
28 );
29}
30
31// === PREZENTACYJNY: widok szczegółów ===
32function CrewDetail({ member }) {
33 if (!member) return <div className="detail-empty">Wybierz członka załogi</div>;
34
35 return (
36 <div className="crew-detail">
37 <div className="detail-avatar">{member.name[0]}</div>
38 <h2>{member.name}</h2>
39 <p className="detail-role">{ROLE_LABELS[member.role]}</p>
40 <div className="stats">
41 <div className="stat"><span>Misje</span><strong>{member.missions}</strong></div>
42 <div className="stat"><span>Staż</span><strong>{member.exp} lat</strong></div>
43 <div className="stat"><span>Ocena</span><strong>{member.rating}/5</strong></div>
44 </div>
45 </div>
46 );
47}
48
49// === CUSTOM HOOK (nowoczesny zamiennik kontenera) ===
50function useCrew() {
51 const [members, setMembers] = useState([]);
52 const [selectedId, setSelectedId] = useState(null);
53 const [loading, setLoading] = useState(true);
54
55 useEffect(() => {
56 // Symulacja pobierania danych z API
57 const timer = setTimeout(() => {
58 setMembers([
59 { id: 1, name: 'Komandor Nova', role: 'captain', active: true, missions: 42, exp: 15, rating: 5 },
60 { id: 2, name: 'Dr Stellar', role: 'medic', active: true, missions: 28, exp: 8, rating: 4 },
61 { id: 3, name: 'Astro', role: 'engineer', active: false, missions: 35, exp: 12, rating: 5 },
62 { id: 4, name: 'Ra', role: 'navigator', active: true, missions: 50, exp: 20, rating: 5 },
63 { id: 5, name: 'Nebula', role: 'scientist', active: true, missions: 19, exp: 5, rating: 4 },
64 ]);
65 setLoading(false);
66 }, 800);
67 // Sprzątanie: gdy komponent zniknie przed czasem, anulujemy odliczanie
68 return () => clearTimeout(timer);
69 }, []);
70
71 const selectedMember = members.find(m => m.id === selectedId) || null;
72
73 return { members, selectedId, setSelectedId, selectedMember, loading };
74}
75
76// === KOMPONENT STRONY ===
77export default function App() {
78 const { members, selectedId, setSelectedId, selectedMember, loading } = useCrew();
79
80 if (loading) {
81 return <div className="loading"><div className="spinner" /><p>Ładowanie danych załogi...</p></div>;
82 }
83
84 return (
85 <div className="app">
86 <h1>Wzorzec Container / Presentational</h1>
87 <p className="subtitle">Logika w hooku, UI w komponentach</p>
88 <div className="layout">
89 <div className="list-panel">
90 <h3>Załoga ({members.length})</h3>
91 <CrewList members={members} selectedId={selectedId} onSelect={setSelectedId} />
92 </div>
93 <div className="detail-panel">
94 <CrewDetail member={selectedMember} />
95 </div>
96 </div>
97 </div>
98 );
99}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego służy prop 'children' w kontekście wzorca kompozycji?
2. Która cecha NIE jest charakterystyczna dla komponentu prezentacyjnego (Presentational)?
Zadania praktyczne w grze
- Edytor kodu
Dokończ dwa komponenty zbudowane przez kompozycję. SpaceCard to kontener (containment). ___BLANK1___: kolor ramki to wartość z obiektu VARIANT_COLORS dla propa variant (info, warning albo danger). ___BLANK2___: w treści karty pojawia się wszystko, co rodzic wpisze między <SpaceCard> a </SpaceCard>. SpaceLayout ma trzy sloty: ___BLANK3___ wyświetla w <header> element z propa header, ___BLANK4___ wyświetla w <aside> element z propa sidebar, a dzieci trafiają do <main>.
- Klikanie w kolejności
Ułóż kod specjalizowanego komponentu LaunchButton, który renderuje SpaceButton z gotowymi propsami:
- Edytor kodu
Rozdziel logikę od widoku. Hook usePlanets dostaje funkcję loadPlanets (domyślnie symulowane API fetchPlanets) i ładuje dane w useEffect. ___BLANK1___: zapisz otrzymane dane w stanie planets. ___BLANK2___: selectedPlanet to planeta, której id równa się selectedId, wyliczona podczas renderu z tablicy planets (gdy nic nie wybrano: null). PlanetList to komponent prezentacyjny: ___BLANK3___ po kliknięciu przycisku woła onSelect z id tej planety. ___BLANK4___: App przekazuje do PlanetList funkcję z hooka, która zmienia zaznaczenie.