Sprawdź na co uważać, gdy w kodzie używającym Reduxa chcesz przejść z connect na hooki

Spis Treści

  1. Wprowadzenie
  2. Connect
    1. mapStateToProps
    2. mapDispatchToProps
    3. mergeProps
  3. Hooki React Redux
    1. useDispatch
    2. useSelector
  4. Ryzyka związane z przechodzeniem z connect na hooki
    1. Porównanie kodu connect i hooków
      1. Kod przykładu na Github
      2. Interaktywny przykład na Github Pages
    2. Na co uważać przy refaktorze?
  5. Podsumowanie

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:

Efekt działania aplikacji

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:

Drzewo komponentów projektu

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.