Moduł 5 - Zajęcia 8 - Kontekst i referencje

W React, dane zawsze przekazywane są z góry na dół przez propsy, a to czasami może być niewygodne. Przykładowo, dane globalne, które potrzebne są w wielu komponentach na różnych poziomach aplikacji (lokalizacja, dark/white theme (zmienna decydująca czy strona ma się wyświetlać w kolorystyce czarnej czy białej), stan autoryzacji i inne).

Kontekst pozwala na przekazywanie danych głęboko w drzewie komponentów bez jawnego przekazywania propsów do elementów pośrednich na każdym poziomie.

Nie wykorzystuj kontekstu, aby uniknąć przekazywania propsów kilka poziomów w dół. Ten mechanizm przeznaczony jest dla wąskiego spektrum zadań.

Przekazywanie danych w Context

Dokumentacja useContext

Funkcja createContext()

              import { createContext } from "react";
              const MyContext = createContext(defaultValue);
          
  • Tworzy obiekt kontekstu zawierający parę komponentów <Context.Provider> (dostawca) i <Context.Consumer> (użytkownik).
  • Pozwala komponentom subskrybować zmiany kontekstu niezależnie od głębokości zagnieżdżenia.
  • Jeden provider może być związany z wieloma konsumentami.
  • Providery mogą być zagnieżdżane - umieszczone jeden w drugim.

Hook useContext()

Zwraca bieżącą wartość kontekstu z najbliższego skorelowanego komponentu <Provider>.

            import { createContext, useContext } from "react";

            const MyContext = createContext();
            const contextValue = useContext(MyContext);
          
  • Wymaga jednego argumentu - referencji do utworzonego kontekstu.
  • Zwróci wartość kontekstu najbliższego providera.
  • Za każdym razem, kiedy wykryta zostanie nowa wartość kontekstu, useContext wymusi re-render komponentu.

Funkcja createContext()

Niewygodnie jest za każdym razem importować odnośnik do obiektu kontekstu, dlatego dobrą praktyką jest utworzenie custom hook.

            import { createContext, useContext } from "react";

            const MyContext = createContext();

            // Importujemy i wykorzystujemy ten hook w komponentach
            export const useMyContext = () => useContext(MyContext);
          

Kontekst użytkownika

Napiszmy kontekst do przechowywania informacji o bieżącym użytkowniku.

            userContext.js

            import { createContext, useContext } from "react";

            export const UserContext = createContext();

            export const useUser = () => useContext(UserContext);
          

Obejmujemy w Provider całe drzewo komponentów. Można to zrobić w komponencie App lub bezpośrednio w głównym pliku index.js.

            index.js
            import { UserContext } from "path/to/userContext.js";

            ReactDOM.createRoot(document.getElementById("root")).render(
              <UserContext.Provider value={{ username: "Mango" }}>
                <App />
              </UserContext.Provider>
            );
          

Dodajemy w <App> komponent menu użytkownika, w którym wydobędziemy wartość kontekstu i wyświetlimy nazwę użytkownika.

            App.jsx
            import { UserMenu } from "path/to/UserMenu";

            const App = () => {
              return (
                <div>
                  <UserMenu />
                </div>
              );
            };
          

Wykorzystujemy nasz custom hook useUser, aby wydobyć wartość kontekstu.

            UserMenu.jsx

            import { useUser } from "path/to/userContext.js";

            export const UserMenu = () => {
              const { username } = useUser();

              return (
                <div>
                  <p>{username}</p>
                </div>
              );
            };
          

Customowy komponent providera

W powyższym przykładzie wartość kontekstu była statyczna. Możemy jednak równie dobrze przekazywać tam dynamiczne wartości, a nawet stan. Stwórzmy teraz customowy komponent providera <UserProvider>, w którym zawrzemy logikę dotyczącą uwierzytelniania użytkownika (stan oraz metodę do jego zmiany).

            userContext.jsx

            import { createContext, useContext, useState } from "react";

            const UserContext = createContext();

            export const useUser = () => useContext(UserContext);

            export const UserProvider = ({ children }) => {
              const [isLoggedIn, setIsLoggedIn] = useState(false);
              const [username, setUsername] = useState(null);

              const logIn = () => {
                setIsLoggedIn(true);
                setUsername("Mango");
              };

              const logOut = () => {
                setIsLoggedIn(false);
                setUsername(null);
              };

              return (
                <UserContext.Provider value={{ isLoggedIn, username, logIn, logOut }}>
                  {children}
                </UserContext.Provider>
              );
            };
          

Owijamy całe drzewo komponentów customowym providerem. Można to zrobić w komponencie App lub bezpośrednio w głównym pliku index.js.

            import { UserProvider } from "path/to/userContext";

            ReactDOM.createRoot(document.getElementById("root")).render(
              <UserProvider>
                <App />
              </UserProvider>
            );
          

W komponencie <App>, tak jak poprzednio, renderujemy komponent menu użytkownika.

            App.jsx

            import { UserMenu } from "path/to/UserMenu";

            const App = () => {
              return (
                <div>
                  <UserMenu />
                </div>
              );
            };
          

Wykorzystujemy useUser

            UserMenu.jsx
            import { useUser } from "path/to/userContext";

            export const UserMenu = () => {
              const { isLoggedIn, username, logIn, logOut } = useUser();

              return (
                <div>
                  {isLoggedIn && <p>{username}</p>}
                  {isLoggedIn ? (
                    <button onClick={logOut}>Log out</button>
                  ) : (
                    <button onClick={logIn}>Log in</button>
                  )}
                </div>
              );
            };
          

Hook useRef

Ref pozwala otrzymać bezpośredni dostęp do wyrenderowanego węzła DOM oraz przypisanych do niego metod. Jest również wykorzystywany jako odpowiednik pól publicznych, które definiowaliśmy na klasowych komponentach React. Przypisanie wartości do zmiennej (const lub let) w ramach komponentu funkcyjnego nigdy nie będzie stabilne podczas re-renderów. Z ratunkiem przychodzi useRef, który pozwala na przetrzymywanie przypisanej wartości pomimo re-renderów komponentu.

Tworzenie

Obiekt 'ref' tworzy się hookiem useRef(). Obiekt ten ma w momencie utworzenia jedną właściwość: 'current'. React zadba, aby każda wartość przypisana do ref.current była stabilna i nie zmieniała się podczas re-renderów.

Przypisanie referencji elementu DOM do refa wykonuje się z użyciem atrybutu ref (skrót od reference), dostępnego na każdym elemencie DOM.

            import { useRef } from "react";

            const App = () => {
              const btnRef = useRef();

              return <button ref={btnRef}>Button with ref</button>;
            };
          

Cykl życiowy refa

React przypisze właściwości current referencję do elementu DOM, po tym jak komponent zostaje zamontowany i null po odmontowaniu. Dlatego wartość refa dostępna jest dopiero po zamontowaniu.

            import { useState, useRef } from "react";

            const App = () => {
              const [value, setValue] = useState(0);
              const btnRef = useRef();

              // null przy pierwszym renderowaniu
              // referencja do elementu DOM przy wszystkich kolejnych
              console.log(btnRef.current);

              useEffect(() => {
                // Efekt wykonuje się zawsze po zamontowaniu komponentu,
                // dlatego ref wewnątrz będzie posiadał referencję do elementu DOM
                console.log(btnRef.current);
              });

              const handleClick = () => {
                // Obsługa kliknięć również odbywa się po zamontowaniu,
                // dlatego ref wewnątrz będzie posiadał referencję do elementu DOM
                console.log(btnRef.current);
              };

              return (
                <>
                  <button onClick={() => setValue(value + 1)}>
                    Update value to trigger re-render
                  </button>
                  <button ref={btnRef} onClick={handleClick}>
                    Button with ref
                  </button>
                </>
              );
            };
          

Brak reaktywności

Refy to nie stan, nie są reaktywne, dlatego zmiana wartości refa nie wpływa na aktualizację komponentu i nie wywołuje ponownego renderowania.

            import { useEffect, useRef } from "react";

            const App = () => {
              const valueRef = useRef(0);

              useEffect(() => {
                // Wykona się tylko jeden raz po zamontowaniu.
                // Późniejsza aktualizacja wartości refa nie wywoła aktualizacji komponentu
                console.log(valueRef.current);
              });

              const handleClick = () => {
                valueRef.current += 1;
              };

              return <button onClick={handleClick}>Click to update ref value</button>;
            };
          

Refy można wykorzystywać także jako magazyn arbitralnych wartości, niezmieniających się między renderami komponentu. W przykładzie poniżej, do hooka useRef przekazano wartość początkową właściwości current - liczbę 0.

            const valueRef = useRef(0);
          

Brak reaktywności

Utwórzmy komponent Player do odtwarzania wideo, wykorzystując natywny tag <video>. Aby włączyć i zatrzymać odtwarzanie należy wywołać metody HTMLMediaElement.play() i HTMLMediaElement.pause(), gdzie HTMLMediaElement to element <video>. Wykorzystujemy ref w celu otrzymania dostępu do elementu DOM i jego metod.

            import { useRef } from "react";

            const Player = ({ source }) => {
              const playerRef = useRef();
              const play = () => playerRef.current.play();
              const pause = () => playerRef.current.pause();

              return (
                <div>
                  <video ref={playerRef} src={source}>
                    Sorry, your browser does not support embedded videos.
                  </video>
                  <div>
                    <button onClick={play}>Play</button>
                    <button onClick={pause}>Pause</button>
                  </div>
                </div>
              );
            };

            const App = () => {
              return <Player source="http://media.w3.org/2010/05/sintel/trailer.mp4" />;
            };
          

Przekierowanie refów

Dotychczas przkazywaliśmy refy to parametru ref na elementach DOM, a co gdybyśmy chcieli przekazać go do komponentu React? W przypadku komponentu klasowego nie będzie z tym problemu, natomiast komponenty funkcyjne nie mają w React takiej możliwości, przynajmniej domyślnie. Z pomocą przychodzi funkcja forwardRef, która automatycznie przekazuje propsy otrzymane od komponentu rodzica do jego elementów dzieci. Dzięki temu możemy przypisać ref - zadeklarowany w rodzicu - do elementu znajdującego się w komponencie dziecku, uzyskując w ten sposób referencję do elementu dziecka, dostępną w rodzicu.

            import { forwardRef, useRef, useEffect } from "react";

            const CustomButton = forwardRef((props, ref) => (
              <button ref={ref}>{props.children}</button>
            ));

            const App = () => {
              const btnRef = useRef();

              useEffect(() => btnRef.current.focus(), []);

              return <CustomButton ref={btnRef}>Button with forwarded ref</CustomButton>;
            };
          

Dokumentacja useRef z dwoma przykładami

Hook useMemo

Czasami komponenty muszą wykonywać kosztowne obliczenia. Na przykład w trakcie pracy z dużą listą pracowników firmy. W takim przypadku można spróbować zwiększyć wydajność komponentu przy pomocy memoizacji.

Metoda optymalizacji wykorzystywana do przyspieszenia pracy programów komputerowych. Rezultat wywołania funkcji z danymi argumentami jest zapisywany (cache). Kolejne wywołania funkcji z takimi samymi wartościami argumentów zwracają zapamiętany wynik i nie obliczają go ponownie.

Hook useMemo() wykorzystuje koncepcję memoizacji, to znaczy zwraca zapamiętany (zkeszowany) wynik obliczeń. Może to zwiększyć wydajność aplikacji, jeśli jest stosowne do zapobiegania kosztownym obliczeniom podczas renderowania.

            const memoizedValue = React.useMemo(
            // compute
              () => computeExpensiveValue(a, b),
            // deps
              [a, b]
            );
          

Hook przyjmuje dwa argumenty - anonimową funkcję, która powinna zwracać wartość (to właśnie ona będzie memoizowna) i tablicę zależności (deps). Jeżeli tablica zależności nie została zdefiniowana, wartość będzie obliczać się przy każdym renderowaniu, co w rezultacie czyni wykorzystanie useMemo() bezsensownym.

Funkcja przekazana do useMemo zostanie wywołana podczas pierwszego renderowania komponentu, a jej wynik zapamiętany i zwrócony z hooka. Jeżeli podczas następnych renderowań zależności nie zmienią się, hook nie wywoła ponownie funkcji, tylko zwróci zapisany wcześniej wynik. Jeżeli któraś z zależności się zmieniła, hook wywołuje funkcję ponownie, a następnie zapamiętuje i zwraca nową wartość.

PODSUMUJMY

  • Memoizacja to zapamiętywanie wartości, aby nie trzeba było jej ciągle obliczać.
  • Memoizację opłaca się stosować tylko dla kosztownych obliczeń.
  • useMemo() wykonuje obliczenie wartości przynajmniej jeden raz.
  • useMemo() zwraca zapamiętaną wartość.
  • useMemo() uruchamia ponowne obliczenia tylko w przypadku aktualizacji którejś z zależności.
  • Obowiązkowo należy przekazać zależności, w innym wypadku stosowanie useMemo() nie ma sensu.

Przeanalizuj kod w następującym przykładzie. W stanie przechowywana jest tablica łańcuchów i wartość szukanego zapytania. [Opuszczamy kod dodania elementów do tablicy i zmiany wartości zapytania].

            const App = () => {
            const [planets, setPlanets] = useState(["Earth", "Mars", "Jupiter", "Venus"]);
            const [query, setQuery] = useState("");

            const filteredPlanets = planets.filter(planet => planet.includes(query));

            return (
              <div>
                {filteredPlanets.map(planet => (
                  <div key={planet}>{planet}</div>
                ))}
              </div>
            );
          };
          

Za każdym razem, gdy zmieni się wartość planets lub query, komponent będzie renderowany ponownie. W rezultacie wartość filteredPlanets zostanie obliczona ponownie. To zupełnie normalne! W takim przypadku niepotrzebna jest żadna memoizacja.

Teraz wyobraź sobie, że komponent <App> zawiera dodatkowy stan lub otrzymuje jakiś props, nie wpływający na planety.

            const App = ({ someProp }) => {
              const [planets, setPlanets] = useState(["Earth", "Mars", "Jupiter", "Venus"]);
              const [query, setQuery] = useState("");
              const [clicks, setClicks] = useState(0);

              const filteredPlanets = planets.filter(planet => planet.includes(query));

              return (
                <div>
                  <div>Some prop: {someProp}</div>
                  <button onClick={() => setClicks(clicks + 1)}>
                    Number of clicks: {clicks}
                  </button>
                  <div>
                    {filteredPlanets.map(planet => (
                      <div key={planet}>{planet}</div>
                    ))}
                  </div>
                </div>
              );
            };
          

Za każdym razem, gdy zmienia się stan clicks lub props someProp, komponent będzie renderowany ponownie. Doprowadzi to do ponownego obliczenia filteredPlanets i przerenderowania drzewa komponentów, mimo iż wartości planets i query nie zmieniły się! W takim wypadku może być warto memoizować obliczanie filteredPlanets.

            import { useMemo } from "react";

            const App = ({ someProp }) => {
              const [planets, setPlanets] = useState(["Earth", "Mars", "Jupiter", "Venus"]);
              const [query, setQuery] = useState("");
              const [clicks, setClicks] = useState(0);

              const filteredPlanets = useMemo(
                () => planets.filter(planet => planet.includes(query)),
                [planets, query]
              );

              return (
                <div>
                  <div>Some prop: {someProp}</div>
                  <button onClick={() => setClicks(clicks + 1)}>
                    Number of clicks: {clicks}
                  </button>
                  <div>
                    {filteredPlanets.map(planet => (
                      <div key={planet}>{planet}</div>
                    ))}
                  </div>
                </div>
              );
            };
          

To samo dotyczy kosztownych operacji, na przykład wykorzystanie długiego cyklu for. Kosztowne obliczenia mogą być stratą czasu, co z pewnością doprowadzi do pogorszenia responsywności interfejsu.

WIĘCEJ NIE ZNACZY LEPIEJ: Nie trzeba memoizować wszystkiego, gdyż - paradoksalnie - może to doprowadzić do utraty wydajności. Memoizacja również zajmuje pamięć obliczeniową. Ciągłe wykonywanie prostych obliczeń jest wciąż "tańsze" niż ich memoizacja. Używaj więc useMemo() z rozwagą, a doświadczenie przyjdzie z czasem.

Dokumentacja useMemo z dwoma przykładami