Hooks w React.js to najnowszy sposób zarządzania stanem komponentów, który został dodany w wersji 16.8. Dzięki nim, stało się możliwe tworzenie komponentów funkcyjnych, które posiadają stan oraz implementację cyklu życia. Hooki są również odpowiedzią na bolączki dotychczasowych rozwiązań służących do tworzenia reużywalnej logiki pomiędzy komponentami.
Każdy hook to funkcja. React udostępnia nam kilka podstawowych hooków, które pozwalają tworzyć na ich podstawie własne, niestandardowe hooki.
Podstawową koncepcją zastosowaną w hooks od której warto zacząć jest nazewnictwo. Nazwy funkcji, które są hookami zaczynają się od use i tak powinniśmy nazywać również własne hooki, które będziemy tworzyć.
Podstawowe funkcje hooków udostępnionych przez React.js:
- useState - stan komponentu
- useReducer - stan komponentu w formie reducera
- useEffect - cykle życia komponentu
- useContext - odczytywanie kontekstu (globalnej wartości)
- useMemo - memoizacja wartości
- useCallback - memoizacja callbacku
Nie są to wszystkie hooki, które udostępnia nam React.js, lecz to ich będziemy najczęściej używać przy tworzeniu aplikacji (w szczególności useState i useEffect).
useState
Pierwszym hookiem który omówimy to useState
. Jest to jeden z najczęściej używanych hooków i służy do przechowywania stanu.
Przykładowy komponent, który wykorzystuje ową funkcję:
import { useState } from 'react';
export const ClickCounter = () => {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Licznik kliknięć: {count}
</button>
);
}
Funkcja useState
przyjmuje jako argument wartość domyślną (początkową) stanu, w naszym przypadku jest to 0
, oraz zwraca tablicę której pierwszym elementem jest aktualna wartość stanu,
a drugim funkcja służąca do zmiany wartości stanu.
Dzięki użyciu destrykturyzacji, możemy w wygodny sposób wypakować wartości ze zwracanej tablicy. W atrybucie onClick
przycisku, dodajemy funkcję która będzie zwiększać wartość naszego count
o 1, po każdym naciśnięciu przycisku. Wartość useState
może przyjąć dowolny typ jak na przykład obiekt, tablica…
Setter, czyli w tym przypadku funkcja o nazwie setCount
, oprócz zaaktualizowanej wartości może również przyjmować callback (funkcję zwracającą wartość zmienionego state) którego argumentem jest aktualny (nieprzestarzały) stan. Jest to o tyle istotne, że aktualizacja stanu
w React.js jest asynchroniczna i może się okazać że wartość którą odczytujemy jest przestarzała. Rozwiązaniem tego problemu i dobrą praktyką jest więc przekazywanie do settera funkcji i na podstawie otrzymywanego w niej argumentu wprowadzanie zmian w state.
import { useState } from 'react';
export const ClickCounter = () => {
const [count, setCount] = useState(0);
return (
// Zamiast korzystać ze zmiennej `count` przekazaliśmy funkcję,
// która pobiera aktualną wartość `count` z argumentu.
<button onClick={() => setCount(c => c + 1)}>
Licznik kliknięć: {count}
</button>
);
}
useReducer
Jest to Hook, który również służy do przechowywania stanu, lecz jego implementacja jest wzorowana na funkcji typu reducer, która jest głównie wykorzystywana w bibliotece do zarządzania stanem globalnym o nazwie
Redux
.
const counterReducer = (state: number, action: IncrementAction): number => {
switch(action.type) {
case 'increment': return state + 1;
// Rzucając błąd w opcji "default", mamy pewność, że nie wywołamy funkcji dispatch
// na nieznanym typie akcji, czyli nieobsłużonym przez naszego switcha.
default: throw new Error('Unknown action type');
}
}
export const ClickCounter = () => {
const [state, dispatch] = useReducer(counterReducer, 0);
return (
<button onClick={() => dispatch({ type: 'increment' })}>
Licznik: {state}
</button>
);
}
type IncrementAction = { type: 'increment' };
Funkcja useReducer
przyjmuje jako pierwszy argument funkcję typu reducer (czyli taką której pierwszym argumentem jest stan a drugim akcja, oraz zwraca
stan), a jako drugi drugi argument przyjmuje wartość początkową stanu, w naszym przypadku
wynosi ona 0
.
useReducer(counterReducer, 0);
Podobnie jak useState
, useReducer
zwraca tablicę w której pierwszy element to stan, a drugi to funkcja dispatch
służąca do zmiany wywoływania akcji, które zmieniają stan w reducerze. Przyjmuje ona jako parametr akcję (obiekt składający z właściwości type, oraz dodatkowe pola np. payload zawierający jakieś dane.
Zaletą użycia funkcji useReducer
jest możliwość łatwiejszej organizacji kilku powiązanych ze sobą funkcjonalności, czyli w przypadku gdy wielokrotnie
używamy funkcji useState
do kilku wartości stanu i kod staje się mało przejrzysty. Funkcja useReducer
pozwala również na łatwe eksportowanie reducera do osobnego modułu, co pozwala na odchudzenie logiki komponentu i operowanie na deklaratywnych metodach (dispatch),
a imperatywny kod logiki stanu znajduje się w osobnym pliku. Kolejną zaletą jest fakt, iż funkcja
dispatch nie zmienia referencji pomiędzy re-renderami, co może się przydać w przypadku dużego drzewa
komponentów (przekazywanie propsów w dół).
useEffect
Ten rodzaj Hook’a, jest wykorzystywany gdy potrzebujemy korzystać cykli życia komponentu, czyli na przykład przy pobieraniu danych z API przy pierwszym renderze.
const fetchTodo = async (): Promise<ITodo> => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/100');
const data = await response.json<ITodo>();
return data;
}
const TodoFetcher = () => {
const [todo, setTodo] = useState<ITodo | null>(null);
useEffect(() => {
fetchTodo()
.then(data => setTodo(data));
}, []);
if (todo === null) {
return 'Loading...';
}
return <p>{todo.title}</p>;
}
interface ITodo {
title: string
}
Pierwszym parametrem jaki przekazujemy jest funkcja która ma się wykonać, a drugim jest tablica, która decyduje o tym kiedy ta funkcja ma się wykonać. Nie podając drugiego parametru,
kod w useEffect
będzie wykonywany przy każdym re-renderze komponentu. Zawartość tablicy decyduje z kolei przy zmianie jakich wartości, funkcja useEffect
ma się wykonać. Podając pustą tablicę, nasza funkcja wykona się jedynie przy pierwszym renderze.
Rozbudujmy teraz nasz przykład o pobieranie todo na podstawie dynamicznego id.
import { useState, useEffect } from 'react';
// Dodaliśmy parametr todoId.
const fetchTodo = async (todoId: number): Promise<ITodo> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId}`);
const data = await response.json();
return data;
}
export const TodoFetcher = () => {
const [todo, setTodo] = useState<ITodo | null>(null);
// Nowa wartość stanu określająca id todo które ma się pobrać.
const [todoId, setTodoId] = useState(100);
useEffect(() => {
fetchTodo(todoId)
.then(data => setTodo(data));
// Tablica zawiera teraz wartość todoId, aby przy każdej zmianie funkcja
// ponownie się uruchamiała i pobierała dane dla todo o nowym id.
}, [todoId]);
if (todo === null) {
return 'Loading...';
}
return (
<>
<span>Todo o id ({todoId})</span>
<p>{todo.title}</p>
<button onClick={() => setTodoId(id => id + 1)}>
Zwiększ id todo o 1
</button>
</>
);
}
interface ITodo {
title: string
}
Wciśnięcie przycisku “Zwiększ id” powoduje inkrementację id, pobranie nowego todo i wyświetlenie go.
useContext
Hook useContext
służy do korzystania z kontekstu, czyli wartości, którą możemy dzielić globalnie z innymi komponentami. Aby odczytać wartość kontekstu, wystarczy do funkcji useContext
podać nasz kontekst.
import { useContext, createContext } from 'react';
const LanguageContext = createContext('en');
function useLanguage() {
// Odczytanie wartości kontekstu.
const language = useContext(LanguageContext);
return language;
}
useMemo
Kolejny z hooków to useMemo. Służy on do memoizacji, czyli zapamiętywania wyniku operacji aby nie obliczać go ponownie w przypadku gdy żadne dane się nie zmieniły. Najczęściej używa się go do obciążających operacji obliczeniowych. Oczywiście nie jest to złoty środek i jako rozwiązanie optymalizacyjne, również niesie ze sobą pewne koszty wydajnościowe.
const [counter, setCounter] = useState(0);
const doubledCounter = useMemo(() => {
// Funkcja, której zwracana wartość jest memoizowana.
return counter * 2;
// Tablica zależności.
// Tak jak w przypadku useEffect, decyduje o tym, kiedy funkcja ma się ponownie wykonać.
// Umieszczamy w niej wszystkie zmienne stanu, z których korzystamy w funkcji.
}, [counter]);
useCallback
Ostatni hook jaki pozostał to useCallback. Działa on podobnie jak useMemo i służy do memoizacji, lecz zamiast wartości z funkcji, zwraca memoizowaną funkcję. Rozwiązanie to przydaje się gdy potrzebujemy funkcję, której referencja nie zmienia się przy każdym renderze.
const [counter, setCounter] = useState(0);
// Tym razem zwracana wartość to nie liczba,
// lecz funkcja której uruchomienie zwróci liczbę.
const doubledCounterCallback = useCallback(() => {
return counter * 2;
}, [counter]);