Wzory projektowe w React: od klasycznego programowania obiektowego do nowoczesnych Hooków
Wyjaśnia, w jaki sposób klasyczne wzorce oprogramowania takie jak Singleton, Factory i Observer znajdują zastosowanie w React, a także wzorce specyficzne dla Reacta, takie jak HOC-y, Hooks i składane komponenty.
Wielu programistów, którzy dopiero zaczynają pracę z Reactem, nie zdaje sobie od razu sprawy, że wzory projektowe mogą być stosowane poza systemami backendowymi. Powszechnym błędem jest przypuszczanie, że te koncepcje dotyczą wyłącznie architektury serwerowej, by później, podczas profesjonalnego tworzenia aplikacji frontendowych, odkryć, że wiele z tych wzorów jest już stosowanych instynktownie, bez żadnej świadomej etykiety przypisanej do nich.
Wzory projektowe to w istocie sprawdzone, wielokrotnie używalne szablony do rozwiązywania problemów, które pojawiają się w projektach programistycznych raz za razem. Gdy potrzebujesz, aby twoja baza kodu była zorganizowana, dobrze skonstruowana i logicznie powiązana, te wzory stanowią dla ciebie plan działania. Funkcjonują one jako sformalizowane najlepsze praktyki, które poprawiają jakość kodu i wydłużają czas, przez jaki pozostaje on łatwy w utrzymaniu.
Jednymi z największych zalet wzorców projektowych są możliwość ponownego użycia, łatwość konserwacji, skalowalność oraz zwiększenie szybkości i efektywności. Zanim przejdziemy do wzorców specyficznych dla React, warto przyjrzeć się podstawowym wzorcom inżynierii oprogramowania, które istniały już przed frameworkami frontendowymi.
Klasyczne wzorce inżynierii oprogramowania
Są to wzorce, które istnieją niezależnie od konkretnego języka i mogą być stosowane zarówno w paradygmatach obiektowych, jak i funkcyjnych. Sam React wykorzystuje kilka z nich w swojej strukturze, a inżynierowie pracujący nad kodem React używają ich do organizacji stanu, koordynowania zależności w cyklu życia komponentów, zmniejszania rozmiaru pliku z pakietem oraz utrzymywania zrozumiałości skomplikowanej logiki interfejsu użytkownika.
Wzorzec Singleton
Ten wzorzec gwarantuje, że klasa lub obiekt ma dokładnie jedną instancję przez cały czas działania aplikacji, jednocześnie dostarczając pojedynczy globalny punkt dostępu do tej instancji.
Na stronie klienta wzorzec Singleton jest przydatny do zarządzania zasobami współdzielonymi — takimi jak centralne magazyny stanu, obiekty konfiguracji dla całej aplikacji, instancje śledzenia analiz lub pojedynczy współdzielony klient API.
// Singleton API Service
class APIClient {
constructor() {
if (APIClient.instance) {
return APIClient.instance;
}
this.baseURL = "https://api.example.com";
APIClient.instance = this;
}
fetchData(endpoint) {
return fetch(`${this.baseURL}${endpoint}`).then(res => res.json());
}
}
// Any module importing/instantiating this gets the exact same instance
const client1 = new APIClient();
const client2 = new APIClient();
console.log(client1 === client2); // true
W przypadku nowoczesnych modułów ES nie musimy już używać ręcznej logiki sprawdzania instancji — wystarczy po prostu eksportować jeden współdzielony obiekt lub instancję, aby uzyskać zachowanie singletona bez dodatkowych działań.
// apiClient.js
export const apiClient = new APIClient(); // ES modules cache exports automatically
Wzorzec fabryki
Wzorzec fabryki definiuje interfejs do tworzenia obiektów, bez konieczności, by kod wywołujący znał konkretną klasę lub funkcję konstruktora odpowiedzialną za utworzenie danego obiektu.
Ten wzorzec jest szczególnie przydatny, gdy musisz dynamicznie generować elementy interfejsu użytkownika, radzić sobie z odpowiedziami API o różnej strukturze lub tworzyć abstrakcje ukrywające różnice pomiędzy platformami, np. ujednolicając sposób obsługi zdarzeń wejściowych w przeglądarce i aplikacjach mobilnych.
// Button Factory for dynamically rendering UI elements
function createButton(type, config) {
switch (type) {
case 'primary':
return { role: 'btn-primary', label: config.label, onClick: config.onClick };
case 'icon':
return { role: 'btn-icon', icon: config.iconName, onClick: config.onClick };
case 'link':
return { role: 'btn-link', href: config.url };
default:
throw new Error(`Unsupported button type: ${type}`);
}
}
const primaryBtn = createButton('primary', { label: 'Submit', onClick: () => {} });
Wzorzec obserwatora
To jest podstawowy mechanizm stojący za słuchaczami zdarzeń oraz bibliotekami do zarządzania stanem, takimi jak Redux, Zustand i MobX. Warto zaznaczyć, że API Context w React nie opiera się na tym wzorcu.
class EventEmitter {
constructor() {
this.events = {};
}
// Subscribe
on(event, listener) {
if (!this.events[event]) this.events[event] = [];
this.events[event].push(listener);
}
// Publish
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(listener => listener(data));
}
}
}
// Usage
const store = new EventEmitter();
// Component A subscribes to state changes
store.on('userLoggedIn', user => console.log(`Welcome, ${user.name}!`));
// Login Service triggers event
store.emit('userLoggedIn', { name: 'Sarah' });
Wzorzec modułu
Wzorzec ten otacza kod w zamknięciu, dzięki czemu zmienne i funkcje wewnętrzne pozostają prywatne, a dostępna jest tylko celowo wybrana interfejs publiczna.
Zanim pojawiły się natywne moduły ES6, była to standardowa technika służąca do utrzymania czystości globalnego obiektu window oraz zapewnienia prawdziwej prywatności zmiennych w JavaScript.
// Module using IIFE (Immediately Invoked Function Expression)
const ShoppingCartModule = (function () {
// Private variable
const cart = [];
// Private function
function calculateTotal() {
return cart.reduce((sum, item) => sum + item.price, 0);
}
// Public API
return {
addItem(item) {
cart.push(item);
},
getTotal() {
return calculateTotal();
}
};
})();
ShoppingCartModule.addItem({ name: 'Keyboard', price: 100 });
console.log(ShoppingCartModule.getTotal()); // 100
console.log(ShoppingCartModule.cart); // undefined (Private!)
Dziś natywne moduły ES z funkcjami import i export automatycznie zarządzają zakresem dostępu, więc rzadko musimy ręcznie tworzyć zamykania, aby zachować prywatność zmiennych.
// cart.js
const cart = []; // Private to cart.js file
export const addItem = (item) => cart.push(item);
export const getTotal = () => cart.reduce((sum, i) => sum + i.price, 0);
Wzorce projektowania komponentów specyficzne dla React
Poniższe wzorce rozwiązują problemy charakterystyczne dla renderowania interfejsu użytkownika, dzielenia logiki między komponentami, zarządzania stanem w całym drzewie komponentów oraz unikania nadmiernego przenoszenia właściwości.
W tej sekcji omówiono następujące wzorce na poziomie komponentów:
- wzorzec HOC
- wzorzec oparty na hookach
- wzorzec komponentu złożonego
- rozdzielenie na kontener i komponent prezentacyjny
- podejście z użyciem render props
- wzorzec interfejsu użytkownika oparty na sztucznej inteligencji
Wzorzec HOC
Komponent wyższego rzędu, czyli HOC, to jedna z najwcześniejszych technik dostarczonych przez React do radzenia sobie z kwestiami wspólnymi dla wielu komponentów. Wyobraźmy sobie sytuację, w której po zgodzie użytkownika na śledzenie konieczne jest uruchomienie zdarzenia analitycznego zaraz po załadowaniu komponentu. Kodowanie tej logiki bezpośrednio w każdym komponencie strony szybko staje się powtarzalne, a jej późniejsza aktualizacja – na przykład w momencie zmiany SDK analitycznego – przekształca się w poważny problem konserwacyjny. Wzorzec HOC został stworzony właśnie po to, by rozwiązać tego typu problemy.
HOC bierze istniejący komponent i dodaje do niego dodatkowe zachowanie, bez konieczności, by oryginalny komponent był świadomy tego dodatkowego funkcjonowania. To właśnie ta separacja stanowi sedno tego wzorca. Na przykład komponent Page skupia się wyłącznie na renderowaniu, podczas gdy jego otoczenie funkcją withAnalytics(Page) zajmuje się logiką śledzenia osobno.
W nowszych bazach kodu hooki w dużej mierze przejęły tę rolę, ale wzorzec HOC nadal często spotyka się w starszych projektach React.
Wzorzec hooków
Niewiele elementów zmieniło React tak fundamentalnie jak hooki. Wprowadzone w React 16.8 w 2019 roku, stały się one standardowym podejściem do pisania większości nowoczesnego kodu React.
Wzorzec hooków umożliwia programistom wyodrębnianie i ponowne wykorzystywanie logiki związanej ze stanem oraz efektów ubocznych pomiędzy komponentami za pomocą zwykłych funkcji, efektywnie zastępując starsze metody takie jak HOC-y i render props.
Wzorzec złożony
Wzorzec złożony umożliwia grupie komponentów współpracę nad wspólnym stanem i logiką w sposób domyślny, bez konieczności ręcznego przekazywania wszystkiego przez propsy. Najczęściej występuje w złożonych interaktywnych elementach interfejsu, takich jak menu rozwijane, akordeony, karty i menu nawigacyjne.
Container / Wzorzec prezentacyjny
Wzorzec ten zapewnia czyste rozdzielenie zadań poprzez podział obowiązków komponentu na dwie odrębne role: jedną, która zarządza logiką aplikacji, oraz drugą, której zadaniem jest wyłącznie renderowanie interfejsu użytkownika.
Komponent container jest odpowiedzialny za decydowanie o tym, jakie dane powinien zobaczyć użytkownik. On zarządza stanem, obsługuje efekty uboczne i zawiera logikę aplikacji.
Z kolei komponent prezentacyjny zajmuje się sposobem wyświetlania tych danych. Po prostu otrzymuje dane i funkcje callback poprzez props, nie modyfikując przy tym samego danych podstawowych.
We współczesnym rozwoju aplikacji z użyciem React dużo większy nacisk kładzie się na dostosowane hooki niż na podział na komponenty kontenerowe i prezentacyjne. Zamiast tworzyć dedykowany komponent kontenerowy wyłącznie w celu pobierania danych, można przenieść tę logikę pobierania do dostosowanego hooka i wywołać go bezpośrednio w tym komponencie, który go potrzebuje. Dzięki temu zachowuje się separację zadań, a jednocześnie eliminuje się dodatkowe warstwy nawijania komponentów oraz powtarzalny kod szablonowy.
Wzorzec render props
W tym wzorcu funkcja jest przekazywana jako właściwość do komponentu, co daje temu komponentowi kontrolę nad stanem i logiką, pozostawiając decyzję o tym, co ma być wyświetlone, osobie, która go używa.
Podstawową ideą render props jest to, że zamiast aby komponent otaczający sam wyświetlał użytą wcześniej szablonowo interfejs, wykonuje on swoją wewnętrzną logikę, a następnie wywołuje funkcję przekazaną jako właściwość w celu utworzenia odpowiedniego kodu JSX.
W współczesnym Reactie niestandardowe hooki w większości przypadków przejęły rolę render props, które wcześniej służyły do udostępniania czystej logiki danych. Mimo to render props wciąż mają sens, gdy komponent musi zarządzać całym poddrzewem, pozwalając jednocześnie osobie wywołującej na decydowanie o strukturze markupu. Biblioteki UI bez interfejsu użytkownika, takie jak React Aria i TanStack Table, opierają się na tym podejściu, aby zapewnić złożone funkcje — obsługę dostępności, zarządzanie fokusem i podobne aspekty — bez narzucania określonego stylu czy struktury DOM.
Wzorzec UI AI
To dość nowy element na tej liście. Tworzenie interfejsów opartych na sztucznej inteligencji, czy to chatbotów, czy bardziej ogólnych asystentów inteligentnych, wymaga starannego koordynowania między usługami AI w tle a reaktywną warstwą interfejsu użytkownika. Wzorzec AI UI polega zasadniczo na połączeniu backendów opartych na dużych modelach językowych z responsywnymi interfejsami klienta, aby mogły one płynnie obsługiwać rozmowy, odpowiedzi strumieniowe oraz asynchroniczną eksploatację modeli.
Jedną z kluczowych koncepcji jest oddzielenie backendu i warstwy proxy od klienta. Aby uniknąć ujawniania kluczy API oraz prawidłowego zarządzania obciążeniem obliczeniowym, wszystkie żądania związane z AI muszą przechodzić przez warstwę po stronie serwera – coś w rodzaju Next.js Route Handlers lub Node.js API proxy umieszczonego przed Vite. Bezpośrednie wywoływanie usług AI z przeglądarki należy całkowicie unikać.
Literatura pokrewna
- Trzy patterny TypeScript, które poprawiają architekturę aplikacji React — Dowiedz się, w jaki sposób patterny Repository, Observer i Builder wykorzystują system typów TypeScript do tworzenia czystszych i łatwiejszych w utrzymaniu kodów w React i Next.js.
- Typowanie hooków React: useState, useEffect, useReducer i custom hooki — Przeczytaj, jak prawidłowo typować useState, useEffect, useReducer oraz custom hooki w TypeScriptie, a także kiedy wybór TypeScript zamiast zwykłego JavaScripta faktycznie przynosi korzyści.