Hooki w React, których prawdopodobnie nie używasz

Przyjrzyjmy się natywnym hookom w Reakcie i zastanówmy się czy i kiedy warto je używać. Czym są i jak używać takie hooki jak: useLayoutEffect, useDeferredValue, useTransition, czy useReducer

Hooki w React wprowadziły świeżość do tej biblioteki i definitywnie uśmierciły klasowe podejście do pisania komponentów. Od tamtej pory hooki jak useState, useEffect, czy useRef stały się codziennością i ich widok w kodzie nikogo nie dziwi. Podczas nauki dogłębnie poświęcamy się zrozumieniu działania tych najczęstszych konstrukcji, by pomagały w codziennym tworzeniu kodu bez większych przeszkód. Twórcy React stworzyli jednak hooki, być może których nie użyłeś nigdy wcześniej, a które może pomogłyby ci wcześniej znacznie szybciej uporać się z napotkanym problemem. Rzućmy okiem na hooki znacznie rzadziej używane niż useCallback...

useReducer

Na początek weźmy sobie nie tak rzadki hook, jak te, o których powiemy sobie w dalszej kolejności. Mowa tutaj o useReducer, którego działanie przypominają konstrukcje znane z bibliotek do zarządzania globalnym stanem aplikacji, takich jak Redux. useReducer powinien być wykorzystywany wszędzie tam, gdzie mamy dość rozbudowany stan w obrębie komponentu/hooka/contextu, a sam useState do tego nie wystarczy. Mowa tutaj o stanie, który będzie na tyle rozszerzony, że pojedyncza zmienna nie wystarczy/będzie niewydajna, by go efektywnie utrzymywać. Przykładem może tutaj być todo lista, gdzie posiadamy różne typy danych, jak zadania (done i ongoing), czy notatki (wraz ze stanem archved).

Rzućmy okiem na snippet:

import { FC, useReducer } from 'react';
import produce from 'immer';

enum ActionType {
  ADD = 'ADD',
  DELETE = 'DELETE',
}

type ReducerStateType = {
  ongoing: string[];
  completed: string[];
};

const initialTodosState: ReducerStateType = {
  ongoing: [],
  completed: [],
};

const todosReducer = (state: ReducerStateType, action: { type: ActionType; payload: string }) => {
  switch (action.type) {
    case ActionType.ADD:
      return produce(state, (draft) => {
        draft.ongoing.push(action.payload);
      });

    case ActionType.DELETE:
      return produce(state, (draft) => {
        draft.ongoing.splice(
          draft.ongoing.findIndex((todo) => todo === action.payload),
          1
        );
      });

    default:
      throw state;
  }
};

let i = 0;

const Todos: FC = () => {
  const [todos, dispatch] = useReducer(todosReducer, initialTodosState);

  return (
    <>
      <button type='button' onClick={() => dispatch({ type: ActionType.ADD, payload: `todo ${(i += 1)}` })}>
        add todo!
      </button>
      {todos.ongoing.map((todo) => (
        <div onClick={() => dispatch({ type: ActionType.DELETE, payload: todo })} key={todo}>
          {todo}
        </div>
      ))}
    </>
  );
};

export default Todos;

Znajome? Na pewno korzystałeś z Reduxa. Reducer, który tworzymy to stworzona przez nas prosta funkcja przyjmująca jako argumenty aktualny stan, i akcję z jej typem i payloadem. Zauważ również, że w powyższym fragmencie skorzystałem z Immera, który jest świetnym wyborem do modyfikowania niemutowalnych danych w naszej aplikacji. Z Immera korzystają przede wszystkim takie nakładki na biblioteki do zarządzania stanem, jak Redux Toolkit.

Generalnie useReducer powinniśmy wykorzystywać wszędzie tam, gdzie stan komponentu będzie na tyle duży i rozległy, że sam useState będzie co najwyżej utrapieniem, a nie porządnym podejściem do pisania stanu, gdzie duplikaty danych mnożą się na potęgę. W powyższym wypadku wykorzystując useState, musielibyśmy utrzymać stan w zwykłym obiekcie  lub tworzyć wiele osobnych stanów na każdy z typów todo.

useLayoutEffect

Jest to hook umożliwiający to samo co klasyczny useEffect, z tą różnicą, że w tym wypadku hook wykona się w momencie wyrenderowania całego szkieletu DOM. Jak pisze Kent C. Dodds, w 99% przypadkach użyjemy klasycznej odmiany. Kiedy zatem useLayoutEffect okaże się lepszy? Przede wszystkim w przypadkach, kiedy zechcemy odwołać się do elementów DOM. Hook ten daje nam gwarancję, że referencja do elementu będzie widoczna w momencie jego wykonania. W każdego innego typu manipulacji elementami HTML (badanie pozycji scrolla, wymiary boxów itd.) również okaże się przydatny.

import { FC, useLayoutEffect, useRef } from 'react';

const Layout: FC = () => {
  const ref = useRef(null);

  useLayoutEffect(() => {
    console.log({ useEffectRef: ref });
  });

  return (
    <>
      <div ref={ref} />
    </>
  );
};

export default Layout;

useTransition

Nowy hook wprowadzony oficjalnie w najnowszej (18.) wersji Reacta. Twórcy stawili czoła dość częstemu problemowi zarządzania rozbudowanymi komponentami i prowadzili na to rozwiązanie: concurrency. Nie jest to konwencja, a raczej podejście do implementacji w logice komponentów i nie tylko. Twórcy Reacta udostępniają nam high levelove API, dzięki któremu możemy optymalizować logikę naszych aplikacji. useTransition oraz kolejny, całkiem podobny useDeferredValue są właśnie stworzone do takich celów.

Dzięki useTransition możemy wskazać operację zmiany stanu tak by było ona low-priority. Wskazując taką funkcję możemy mieć pewność, że zmiana stanu będzie przełożona na koszt innych, "zwykłych" operacji w komponencie i wykona się na przykład nieco później. Dzięki takiemu rozwiązaniu, zyskujemy zupełnie nowe możliwości optymalizacji działania komponentów. Przykładowo, możemy wskazać funkcję ustawienia stanu stringa służącego do filtrowania. Wartość stanu będzie ustawiona w takim wypadku asynchronicznie z mniejszym priorytetem, kiedy reszta innych operacji będzie wykonywana z wyższym priorytetem. Taki case może mieć zastosowanie, kiedy zależy nam na ważniejszej operacji, a filtrowanie zostanie opóźnione. Optymalizacja może być też korzystna dla użytkowników posiadających słabszy sprzęt. 

import { FC, useState, useTransition } from 'react';

const Layout: FC = () => {
  const [isPending, startTransition] = useTransition();
  const [filterTerm, setFilterTerm] = useState('');

  function updateFilterHandler(value: string) {
    startTransition(() => {
        setFilterTerm(value);
    });
  }

  return (
    <div id='app'>
      <input type='text' onChange={(event) => updateFilterHandler(event.target.value)} />
      {isPending && <p>Updating List...</p>}
    </div>
  );
};

export default Layout;

Jak widzisz z useTransition destrukturyzujemy dwie rzeczy: booleana isPending oraz funkcję startTransition przyjmującą callback, w którym wskazujemy co chcemy oddelegować niżej.

useDeferredValue

React 18 wprowadził również hooka useDeferredValue, który polega na tym samym co tranzycja. Tutaj jedynie wskazujemy referencję, której wartość chcemy opóźnić względem innych operacji. Hook zwraca nową wartość, którą wykorzystamy do operacji rzeczywistych. Często będzie miało to zastosowanie w wartościach zaciąganych z zewnętrznych zależności, takich jak biblioteki.

import { FC, useDeferredValue, useState, useTransition } from 'react';

const Layout: FC = () => {
  const [isPending, startTransition] = useTransition();
  const [filterTerm, setFilterTerm] = useState('');

  function updateFilterHandler(value: string) {
    setTimeout(() => {
      startTransition(() => {
        setFilterTerm(value);
      });
    }, 1000);
  }

  const defferedValue = useDeferredValue(filterTerm);

  return (
    <div id='app'>
      <input type='text' onChange={(event) => updateFilterHandler(event.target.value)} />
      {defferedValue}
      {isPending && <p>Updating List...</p>}
    </div>
  );
};

export default Layout;

Oba powyższe hooki przypominają nieco w działaniu debounce lub throttling. Dzięki nim możemy oddelegować zmiany w komponentach o niewielką ilość czasu, z tym że tutaj wszystko będzie zależało od prędkości wykonywania kodu, a nie od stałej wartości milisekund.

Podsumowanie

Na co dzień używamy tak pospolite hooki jak: useState, useCallback, czy useRef. Jest to przecież w pełni zasadne i nie ma w tym nic złego. Bywa jednak czasem, że w naszej codziennej pracy zdarzają się przypadki kodowe, których skomplikowanie może być zredukowane przez te nieco mniej powszechne hooki. Dziś spróbowałem opisać te najbardziej istotne z mojej perspektywy. Jest jeszcze kilka nieco mniej używanych, natywnie dostępnych hooków w Reakcie (useId, useImperativeHandle, useDebugValue), których jednak użycie nie jest tak istotne z punktu widzenia codziennego pisania kodu (np. dlatego, że zostały stworzone do użycia w bibliotekach). Do ich studiowania odsyłam do dalszych źródeł.

Źródła

© Damian Kalka 2022
Wszelkie prawa zastrzeżone