Spis Treści
Wprowadzenie
Hooki w świecie Reacta pojawiły się już jakiś czas temu i spotkały się z pozytywnym przyjęciem. Znalazły również zastosowanie w bibliotece Redux – mamy hooki useDispatch
, useSelector
i potrzebny w specyficznych przypadkach useStore
.
Pracując w aplikacji która jest oparta o Reduxowe API connect
myślałem na początku, że nowe hooki Reduxa okażą się bezpośrednim następcą starszego API i że od razu mogę zabrać się do aktualizacji kodu.
Okazało się, że niekoniecznie – przy przejściu na hooki trzeba uważać na pewne rzeczy i o tym będzie ten post.
Ale na początku zróbmy krótki wstęp do rzeczy, o których będziemy tu mówić.
Connect
Connect jest funkcją która opakowuje komponent we wrapper zapewniający możliwość odczytu aktualnych danych z Reduxowego store oraz funkcji które pozwolą wrzucać akcje do obsługi reducerom. Przyjmuje 4 parametry, my wspomnimy tutaj o 3: mapStateToProps, mapDispatchToProps oraz mergeProps.
mapStateToProps
mapStateToProps jest funkcją, która jako parametry przyjmuje stan ze store oraz opcjonalnie obiekt ownProps
, zawierający propsy przekazane do komponentu.
const mapStateToProps = state => ({ count: state.counter.value, })
mapDispatchToProps
mapDispatchToProps to funkcja, która jako parametr przyjmuje funkcję dispatch umożliwiającą wrzucanie akcji do reducerów. Możemy zastosować skróconą notację gdzie zamiast funkcji użyjemy obiektu, który jako pola przyjmie akcje które mają zostać zdispatchowanie:
const mapDispatchToProps = { decrement, increment, incrementByAmount, incrementAsync, }
mergeProps
mergeProps jako parametry przyjmuje efekt wywołania 2 poprzednich metod oraz ownProps
, umożliwia modyfikacje ostatecznej wersji propsów przekazanych do komponentu.
Do czego może posłużyć mergeProps
? Osobiście często używam jej w sytuacji gdy chcę zrobić dispatch akcji zawierającej jakieś dane ze stanu np ID elementu, a do komponentu chcę przekazać zwykłą funkcję – zamiast przekazać i akcję i ID, łącząc je dopiero w komponencie:
// Bez mergeProps: const mapStateToProps = state => ({ selectedUser: state.selectedUser }); const mapDispatchToProps = { loadNotifications }; useEffect(() => { props.loadNotifications(props.selectedUser.id); }, []); // Z mergeProps: const mergeProps = (stateProps, { loadNotifications, ...dispatchProps }, ownProps) => ({ ...stateProps, ...dispatchProps, ...ownProps, onLoad: () => loadNotifications(stateProps.selectedUser.id), }); useEffect(() => { onLoad(); });
Hooki React Redux
Hooki React Redux są w mojej opinii prostszą koncepcją w zrozumieniu niż zaprezentowana wyżej funkcja connect
. Kodu jest nieco mniej i jest on mniej zawiły, jednak ma to swoje minusy. Wspomnimy tutaj o 2 hookach, useDispatch i useSelector.
useDispatch
useDispatch to hook zwracajacy metodę dispatch
. Przekazywane do niej akcje będą wrzucane do reducerów:
const dispatch = useDispatch(); return ( <Counter count={count} decrement={() => dispatch(decrement())} increment={() => dispatch(increment())} incrementAsync={value => dispatch(incrementAsync(value))} incrementByAmount={value => dispatch(incrementByAmount(value))} /> );
useSelector
useSelector jest hookiem zwracającym wartość ze stanu:
const count = useSelector(state => state.counter.value);
Ważną informacją zawartą w dokumentacji jest to, że domyślnie useSelector
używa porównania ===
do tego, czy zwrócić nową wartość i spowodować rerender. Jeżeli chcemy mieć zachowanie analogiczne jak w przypadku connect
gdzie w przypadku zwrócenia obiektu porównywane są wartości w polach tego obiektu (również za pomocą ===
), należy użyć zaimportowanej z pakietu react-redux
funkcji shallowEqual
:
const count = useSelector(state => state.counter.value, shallowEqual);
Ryzyka związane z przechodzeniem z connect na hooki
Choć wydawałoby się naturalnym natychmiastowa chęć przejścia ze “starego” sposobu na “nowy”, rzeczywistość okazuje się bardziej skomplikowana.
Porównanie kodu connect i hooków
Do porównania użyjemy aplikacji CRA w wersji z Reduxem, którą utworzymy poleceniem:
npx create-react-app --template redux
Aplikacja stworzona z tego szablonu zawierać będzie bibliotekę Redux Toolkit która jest zbiorem narzędzi od twórców Reduxa uprzyjemniającą pracę z nim.
Jeśli chcesz poczytać o niej więcej, zapraszam do zapisania się na moją listę mailową:
Z aplikacji wytniemy to co niepotrzebne oraz stworzymy kontenery w 2 wersjach – klasyczna z connect
i nowa z hookami. Końcowy efekt będzie wyglądał następująco:
Szczegółową implementację będzie można znaleźć na Github i Github Pages, skupmy się na samych kontenerach.
Spójrzmy na kod kontenera zbudowanego w oparciu o connect
:
const CounterConnectContainer = props => { return <Counter {...props} /> } const mapStateToProps = state => ({ count: state.counter.value, }) const mapDispatchToProps = { decrement, increment, incrementByAmount, incrementAsync, } export default connect(mapStateToProps, mapDispatchToProps)(CounterConnectContainer);
Kontener opierający się o hooki wygląda tak:
const CounterHooksContainer = () => { const count = useSelector(state => state.counter.value); const dispatch = useDispatch(); return <Counter count={count} decrement={() => dispatch(decrement())} increment={() => dispatch(increment())} incrementAsync={value => dispatch(incrementAsync(value))} incrementByAmount={value => dispatch(incrementByAmount(value))} /> }; export default CounterHooksContainer;
Kod wygląda więc, moim zdaniem, znacznie czytelniej. Drzewo komponentów również jest bardziej czytelne w wersji z hookami, co możemy podejrzeć mając zainstalowane Redux DevTools:
Przy bardziej skomplikowanym kodzie różnica będzie jeszcze bardziej widoczna.
Kod przykładu na Github
Kod przykładu dostępny jest pod adresem:
https://github.com/radek-anuszewski/connect-hooks-demo
Interaktywny przykład na Github Pages
https://radek-anuszewski.github.io/connect-hooks-demo/
Na co uważać przy refaktorze?
Choć teoretycznie wszystko wygląda dobrze, z przejściem na hooki wiąże się ryzyko pewnych problemów wydajnościowych. Problemy został nawet opisane w oficjalnej dokumentacji Reduxa.
Wrapper utworzony przez funkcję connect
automatycznie dodawał pewne optymalizacje wydajnościowe, takie jak implementacja shouldComponentUpdate
która robi porównanie wartości propsów przekazanych do komponentu. Jeśli więc wszystkie propsy na obiekcie wynikowym mają te same wartości nie ma aktualizacji. Używając hooków tracimy ten automatyzm.
useSelector
używa operatora porównania ===
do sprawdzenia, czy należy zrobić aktualizację. Dla typów prostych będzie to wystarczające, jeśli jednak selektor zwraca obiekt narażamy się na niepotrzebne aktualizacje. Dodając do tego fakt, że często stosujemy destrukturyzację zamiast zwracania wartości:
const { id } = useSelector(state => state.user);
Widać jak może być to problematyczne – w opisanym przykładzie id
może być to samo, natomiast referencja do obiektu user
może się zmieniać np. w wyniku aktualizacji danych.
Jednym z rozwiązań problemu jest użycie wspomnianej wcześniej funkcji shallowEqual
która przeniesie zachowanie znane z connect bądź odwołanie się do wartości z typem prostym:
const { id } = useSelector(state => state.user, shallowEqual); //lub const id = useSelector(state => state.user.id);
Kolejnym kłopotem mogą być rerendery spowodowane przez rerendery rodzica, które w klasycznym kodzie obsługiwane są przez connect
. Z pomocą tutaj może nam przyjść użycie React.memo
:
export default React.memo(CounterHooksContainer);
Pewien problem wiąże się również z hookiem useDispatch
, a konkretnie z użyciem funkcji dispatch
. We wspomnianym wcześniej kodzie:
<Counter count={count} decrement={() => dispatch(decrement())} increment={() => dispatch(increment())} incrementAsync={value => dispatch(incrementAsync(value))} incrementByAmount={value => dispatch(incrementByAmount(value))} />
Funkcje przekazywane jako callbacki za każdym razem będą mieć nowe referencje, co będzie powodować nadmiarowe rendery. Właściwy kod powinien używać hooka useCallback:
const onDecrement = useCallback(() => dispatch(decrement()), [dispatch]); const onIncrement = useCallback(() => dispatch(increment()), [dispatch]); return <Counter count={count} decrement={onDecrement} //... />
Ponieważ referencja do funkcji dispatch
zmienia się tylko w rzadkich przypadkach, jej referencja jest niezmienna co spowoduje że referencje do callbacków również nie będą się zmieniać.
Podsumowanie
Jak się okazuje podczas refaktoryzacji należy zachować bardzo dużą uwagę. Nie zawsze connect
da się w prosty sposób zastąpić hookami, należy mieć na uwadze to czy nie występują problemy wymienione w tekście.