Moduł 6 - Zajęcia 11 - Podstawy Redux

1.1 Zarządzanie statusem

Wymagania do funkcjonalności aplikacji ciągle rosną, a w rezultacie zwiększa się ilość statusów interfejsu: asynchroniczne ładowanie danych, wskaźniki ładowania danych, wartości filtrów w trakcie sortowania, status formularzy itp. Biblioteki zarządzania statusem standaryzują przechowywanie statusu aplikacji i pracę z nim, upraszczając w ten sposób proces programowania.

Redux to jedna z najpopularniejszych bibliotek zarządzania statusem aplikacji.

  • Przewidywalność wyników - zawsze istnieje jedno źródło prawdy, store (magazyn) kryjący w sobie status aplikacji i metody do pracy z nim.
  • Wspieranie - istnieje zbiór zasad i dobrych praktyk na temat tego, jak kod powinien być ustrukturyzowany, co czyni go bardziej jednorodnym i zrozumiałym.
  • Narzędzia programisty - wygodne rozszerzenie przeglądarki, w którym dostarczane jest maksimum informacji o statusie aplikacji w trybie czasu rzeczywistego.

1.2 Flux

Redux opiera się na architekturze Flux. Flux to zaproponowany przez Facebooka wzór architektoniczny do budowy SPA (Single Page Application). Sugeruje podzielenie aplikacji na następujące części:

  • Stores
  • Dispatcher
  • Views
  • Actions

Źródło: link

Redux, Flux, CQRS

1.3 Przepływ danych

Bez wykorzystania biblioteki zarządzania statusem, proces aktualizacji danych wygląda następująco:

  • Status przechowywany jest w najbliższym wspólnym komponencie przodku
  • Zmienny status przerzucany jest przez propsy w dół drzewa komponentów.

Spójrz na animowaną ilustrację tego procesu, na której pokazany jest przykład zmiany statusu w różnych częściach aplikacji.

Dla niektórych komponenty występują jako przewodnicy, to znaczy otrzymują props tylko po to, aby przerzucić go głębiej, do komponentu, któremu jest naprawdę potrzebny. Na początku należy przerzucić przez całe drzewo komponentów metodę zmiany statusu, później sam status. To standardowy mechanizm przekazania propsów o kilka poziomów w głąb, nie można go zmienić.

Redux rozwiązuje ten problem poprzez utworzenie store (magazynu), który odpowiada za scentralizowane przechowywanie całego statusu i dostarcza zbioru zasad i metod do jego zmiany. Komponentom pozostaje wywołanie metody do aktualizacji danych i subskrypcja aktualizacji. W ten sposób Redux rozwiązuje problemy przekazywania propsów przez całe drzewo komponentów.

Strumień danych w Redux jest zawsze jednokierunkowy, od komponentów do store i od store do komponentów bez pośredników. To sprawia, że logika aplikacji jest bardziej przewidywalna i łatwa do zrozumienia.

  • Użytkownik, pracując z interfejsem, inicjalizuje wysłanie actions (działań).
  • Store wywołuje wszystkie zadeklarowane reducery, funkcje do zmiany statusu, przekazując im bieżący status (state) i action (działanie).
  • Store przechowuje zaktualizowany status (state) zwracany z reducerów (reducers)
  • Przy aktualizacji statusu (state) renderują się ponownie zależne od niego komponenty.

1.4 Plusy i minusy

Redux to tylko narzędzie do zarządzania statusem aplikacji, które przeznaczone jest do tego, aby pomóc odpowiedzieć na pytanie: "Kiedy i jak zmieniła się określona część statusu". Jeżeli nie masz problemu z zarządzaniem statusem, wykorzystując możliwości React, może być ci trudno zrozumieć plusy Redux. Być może status React to wszystko, co jest ci potrzebne do utworzenia aplikacji.

Wykorzystanie biblioteki zarządzania statusem nie powinno być przyjmowane jako obowiązkowe. Jeżeli aplikacja staje się tak skomplikowana, że nie rozumiesz, gdzie przechowywany jest status i jak się zmienia oraz zdecydujesz, że przechowywanie danych w statusie komponentu React jest niewystarczające, wtedy czas na użycie Redux.

Niemniej jednak wykorzystanie Redux wymaga kompromisów. Nie został on stworzony, aby być najkrótszym lub najszybszym sposobem pisania kodu. Redux stawia określone wymagania: przechowywać status aplikacji w postaci prostej struktury danych (store), opisywać zmiany obiektami (action) i opracowywać zmiany przy pomocy czystych funkcji (reducery).

Redux vs Context

2.1 Menadżer zadań

Będziemy analizować Redux na przykładzie aplikacji menadżera zadań, w którym można utworzyć, usunąć, anulować zadanie jako wykonane i filtrować zadania po statusie. Pozwoli to na analizę standardowych przypadków w trakcie pracy ze zbiorem danych.

W pierwszej kolejności opiszemy bazowe wymagania do interfejsu i logiki pracy aplikacji:

Interfejs powinien się składać z kilku części:

  • Nagłówek z informacjami o zadaniach i filtrami
  • Formularz z polem do wpisywania dla tworzenia nowych zadań
  • Lista zadań
  • W nagłówku należy wyświetlać:
    • Ilość wykonanych i niewykonanych zadań
    • Filtry listy zadań z wartościami «All», «Active» i «Completed»
  • W każdym elemencie listy zadań powinien być:
    • Akapit z tekstem, który do formularza wprowadzał użytkownik w trakcie tworzenia zadania
    • Checkbox przełączenia statusu "wykonano"
    • Przycisk usunięcia zadania

Ostateczny cel - aplikacja, której interfejs będzie wyglądał następująco:

2.2 Projektowanie statusu

Interfejs aplikacji powinien opierać się na jego statusie. Z tego względu w pierwszej kolejności należy zaprojektować status aplikacji, który będzie zawierał najmniejszą ilość wartości, wystarczającą do opisania całej niezbędnej funkcjonalności. Zmniejszy to ilość danych, które trzeba będzie śledzić i aktualizować.

W twojej aplikacji są dwie podstawowe części: lista zadań, z której można otrzymać wszystkie niezbędne dane o ilości i statusie zadań i wartości filtrów listy zadań. To będzie właśnie minimalny niezbędny status.

            const appState = {
                tasks: [],
                filters: {
                    status: "all",
                },
            };
          

STRUKTURA STATUSU: Status Redux to zawsze obiekt, wewnątrz którego dodawane są właściwości dla statusu aplikacji. Dlatego zadeklarowaliśmy właściwość tasks dla tablicy wszystkich zadań i filters dla możliwych filtrów. Status Redux może być na tyle prosty lub skomplikowany, na ile wymaga tego funkcjonalność aplikacji.

Każde zadanie będzie przedstawiane jako obiekt z następującymi właściwościami:

  • id - unikalny identyfikator
  • text - tekst, który wprowadził użytkownik przy utworzeniu
  • completed - flaga wskazująca, czy zadanie zostało wykonane, czy nie

A tak może wyglądać przykład statusu naszej aplikacji z kilkoma zadaniami:

            const appState = {
                tasks: [
                    { id: 0, text: "Learn HTML and CSS", completed: true },
                    { id: 1, text: "Get good at JavaScript", completed: true },
                    { id: 2, text: "Master React", completed: false },
                    { id: 3, text: "Discover Redux", completed: false },
                    { id: 4, text: "Build amazing apps", completed: false },
                ],
                filters: {
                    status: "all",
                },
            };
          

2.3 Projektowanie akcji

Akcje to zdarzenia, które mogą zajść w aplikacji, w tym jako reakcja na działania użytkownika. Zrobimy listę zdarzeń, które mogą się znaleźć w naszej aplikacji:

  • Dodać nowe zadanie z tekstem wprowadzonym przez użytkownika
  • Usunąć zadanie
  • Przełączyć status zadania
  • Zmienić wartość filtru statusu

2.4 Struktura plików projektu

W Redux nie ma standardu struktury plików projektu, tylko ogólne rekomendacje i przykłady, dlatego każdy może wybrać coś dla siebie. Niemniej jednak ważne, aby przemyśleć szablon struktury plików projektu jeszcze przed napisaniem kodu.

W celu oddzielenia logiki Redux od kodu komponentów wystarczy nam utworzyć folder z kilkoma plikami. W małej aplikacji, jak nasz menadżer zadań, to wystarczy.

  • actions.js - plik deklarowania akcji w aplikacji
  • reducer.js - plik deklarowania funkcji-reducerów do aktualizacji statusu
  • constants.js - plik do przechowywania stałych (na przykład wartości filtru statusu)
  • selectors.js - plik deklarowania funkcji-selektorów
  • store.js - plik tworzenia store Redux

Jeżeli w aplikacji jest dużo różnych danych, to odpowiednie będzie podejście "feature based", gdzie dla każdej jednostki tworzy się oddzielny folder wewnątrz folderu redux. Wewnątrz każdej jednostki jest standardowy zestaw plików. W rezultacie otrzymujemy więcej plików, jednak kod logiki Redux został podzielony na jednostki i jest lepiej ustrukturyzowany.

3.1 Instalacja

Dodamy do projektu bibliotekę Redux - zestaw funkcji dla utworzenia store (magazynu), pracy ze statusem aplikacji (state) i wysyłania akcji (zdarzeń, actions).

            npm install redux
          

W celu wykorzystywania React i Redux razem, należy dodać do projektu bibliotekę React Redux. To zbiór komponentów i hooków wiążących komponenty React i Redux store.

            npm install react-redux
          

REDUX VS REDUX TOOLKIT: W materiałach z tych zajęć zapoznamy się z fundamentalnymi koncepcjami biblioteki Redux i obowiązkowo przeanalizujemy je na żywych przykładach. Dalej będziemy wykorzystywać Redux Toolkit - dodatek do bazowych koncepcji i konstrukcji Redux, która oparta jest na dobrych praktykach, upraszcza kod związany z Redux i zapobiega rozpowszechnionym błędom. To oficjalnie rekomendowane podejście do napisania logiki Redux.

4.1 Store

Obiekt, który zawiera pełen status aplikacji, metody dostępu do statusu i wysyłania akcji. W aplikacji może być tylko jeden store. Do utworzenia store istnieje funkcja createStore(), która przyjmuje kilka parametrów i zwraca nowy obiekt store.

            createStore(reducer, preloadedState, enhancer)
          
  • reducer - funkcja z logiką zmiany statusu Redux. Parametr obowiązkowy.
  • preloadedState - początkowy status aplikacji. Powinien to być obiekt takiego samego kształtu, co przynajmniej część statusu.
  • enhancer - funkcja rozszerzenia możliwości store. Parametr nieobowiązkowy.
            src/redux/store.js
            import { createStore } from "redux";

            // Początkowa wartość statusu Redux dla Reducera root,
            // jeżeli nie przekaże się parametru preloadedState. 
            const initialState = {
                tasks: [
                    { id: 0, text: "Learn HTML and CSS", completed: true },
                    { id: 1, text: "Get good at JavaScript", completed: true },
                    { id: 2, text: "Master React", completed: false },
                    { id: 3, text: "Discover Redux", completed: false },
                    { id: 4, text: "Build amazing apps", completed: false },
                ],
                filters: {
                    status: "all",
                },
            };

            // Tymczasem wykorzystujemy reducer, który
            // zwraca tylko otrzymany status
            const rootReducer = (state = initialState, action) => {
                return state;
            };

            export const store = createStore(rootReducer);
          

Po utworzeniu store należy związać go z komponentami React, aby mogły otrzymywać dostęp do store i jego metod. W tym celu w bibliotece React Redux istnieje komponent Provider, który oczekuje jednoimiennego propsu store. Aby dowolny komponent w aplikacji mógł być wykorzystany przez store, owijamy w Provider całe drzewo komponentów.

            src/index.js
            import ReactDOM from "react-dom/client";
            import { Provider } from "react-redux";
            import { store } from "./redux/store";

            ReactDOM.createRoot(document.getElementById("root")).render(
            <Provider store={store}>
                <App />
            </Provider>
            );
          

5.1 Redux DevTools

Narzędzia programisty to rozszerzenie przeglądarki, które dodaje wygodny wizualny interfejs do debugowania zmian statusu aplikacji i śledzenia przepływu danych w Redux, od wysyłania akcji, do zmiany statusu.

Aby zacząć, należy dodać rozszerzenie narzędzi programisty do swojej przeglądarki:

Następnie instalujemy bibliotekę, która pozwala inicjalizować logikę Redux DevTools i związać ją z rozszerzeniem w narzędziach programisty.

            npm install @redux-devtools/extension
          

Na razie nie używamy żadnych dodatkowych zaawansowanych opcji Redux, dlatego importujemy funkcję devToolsEnhancer i wykorzystujemy ją przy tworzeniu store, przekazując jej rezultat jako trzeci argument, zamiast statusu początkowego.

            src/redux/store.js
            import { createStore } from "redux";
            import { devToolsEnhancer } from "@redux-devtools/extension";

            const initialState = {
                tasks: [
                    { id: 0, text: "Learn HTML and CSS", completed: true },
                    { id: 1, text: "Get good at JavaScript", completed: true },
                    { id: 2, text: "Master React", completed: false },
                    { id: 3, text: "Discover Redux", completed: false },
                    { id: 4, text: "Build amazing apps", completed: false },
                ],
                filters: {
                    status: "all",
                },
            };

            const rootReducer = (state = initialState, action) => {
                return state;
            };

            // Tworzymy rozszerzenie store, aby dodać narzędzia programisty
            const enhancer = devToolsEnhancer();

            export const store = createStore(rootReducer, enhancer);
          

PORZĄDEK ARGUMENTÓW: Jeżeli nie potrzebujesz statusu początkowego preloadedState, to wartość enhancer przekazywana jest jako drugi argument. W przeciwnym razie jako trzeci.

Po uruchomieniu projektu poleceniem npm start, w standardowych narzędziach programisty pojawi się nowa zakładka Redux i po przejściu do niej otworzy się Redux DevTools z listą wysłanych akcji po lewej i szczegółowymi informacjami o statusie i akcjach po prawej.

6.1 Subskrypcja store

Aby otrzymać dane ze store, komponenty powinny subskrybować niezbędne dla nich części statusu Redux. W tym celu w bibliotece React Redux istnieje hook [useSelector(selector)](https://react-redux.js.org/api/hooks#useselector). Jako argument przyjmuje funkcję, która deklaruje jeden parametr state - cały obiekt statusu Redux, który zostanie automatycznie przekazany do funkcji poprzez hook useSelector. Ta funkcja nazywana jest selektorem i powinna zwrócić tylko tę część statusu, która jest niezbędna dla komponentu.

            // Importujemy hook import { useSelector } from "react-redux";
            const MyComponent = () => 
            {// Otrzymujemy niezbędną część statusu  const value = useSelector(state => state.some.value);};
          

Dodajemy kod subskrypcji komponentów naszej aplikacji. W celu skupienia uwagi na logice kodu subskrypcji, w przykładach pominiemy stylizację. Pełen kod aplikacji możesz przeanalizować na żywym przykładzie na końcu tej sekcji.

6.2 Filtrowanie po statusie

Zapisujemy możliwe wartości filtra w postaci obiektu, aby ponownie wykorzystywać je w różnych miejscach aplikacji: w komponencie StatusFilter do obliczenia bieżącego aktywnego filtra i wysłania akcji zmiany filtra, w komponencie TaskList do obliczenia listy widocznych zadań, a także funkcji reducera, w której będziemy później opracowywać action zmiany filtra.

            src/redux/constants.js
            export const statusFilters = Object.freeze({  all: "all",  active: "active",  completed: "completed",});
          

OBJECT.FREEZE(): Wykorzystujemy metodę [Object.freeze()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) aby "zamrozić" obiekt wartości filtra i zapobiec jego przypadkowej zmianie po odnośniku w miejscach importu.

Komponentowi StatusFilter potrzebna jest wartość filtra z właściwości statusFilter statusu Redux, dlatego funkcja selektor będzie wyglądać jak state => state.filters.status.

            
            // Importujemy hook
            import { useSelector } from "react-redux";
            // Importujemy obiekt wartości filtra
            import { statusFilters } from "../../redux/constants";

            export const StatusFilter = () => {
            // Otrzymujemy wartość filtra ze statusu Redux
                const filter = useSelector(state => state.filters.status);

                return (
                    <div>
                        <Button selected={filter === statusFilters.all}>All</Button>
                        <Button selected={filter === statusFilters.active}>Active</Button>
                        <Button selected={filter === statusFilters.completed}>Completed</Button>
                    </div>
                );
            };
          

6.3 Lista zadań

Komponentowi TaskList niezbędna jest tablica zadań z właściwości tasks i wartość filtra z właściwości statusFilter. Na bazie tych wartości możemy obliczyć tablicę zadań, które należy zrenderować w interfejsie.

            
            // Importujemy hook
            import { useSelector } from "react-redux";
            import { Task } from "components/Task/Task";
            // Importujemy obiekt wartości filtra
            import { statusFilters } from "../../redux/constants";

            const getVisibleTasks = (tasks, statusFilter) => {
                switch (statusFilter) {
                    case statusFilters.active:
                        return tasks.filter(task => !task.completed);
                    case statusFilters.completed:
                        return tasks.filter(task => task.completed);
                    default:
                        return tasks;
                }
            };

            export const TaskList = () => {
                // Otrzymujemy tablicę zadań ze statusu Redux
                const tasks = useSelector(state => state.tasks);
                // Otrzymujemy wartość filtra ze statusu Redux
                const statusFilter = useSelector(state => state.filters.status);
                // Obliczamy tablicę zadań, które należy wyświetlić w interfejsie
                const visibleTasks = getVisibleTasks(tasks, statusFilter);

                return (
                    <ul>
                        {visibleTasks.map(task => (
                            <li key={task.id}>
                                <Task task={task} />
                            </li>
                        ))}
                    </ul>
                );
            };
          

GDZIE SĄ PROPSY?: Zwróć uwagę na to, że w komponencie TaskList nie ma propsów, jak przy wykorzystaniu statusu React. Komponent App nie musi teraz wiedzieć o tym, że TaskList subskrybuje dane ze store. Wykorzystując Redux, dowolny komponent może bezpośrednio otrzymać dostęp do wartości ze statusu Redux, jeśli jest taka potrzeba.

6.4 Licznik zadań

Komponentowi TaskCounter niezbędna jest tablica zadań z właściwości tasks statusu Redux, dlatego funkcja selektor będzie wyglądała jak state => state.tasks. Na podstawie tych danych możemy obliczyć dane pochodne ilości aktywnych i wykonanych zadań.

            
            // Importujemy hook
            import { useSelector } from "react-redux";

            export const TaskCounter = () => {
                // Otrzymujemy tablicę zadań ze statusu Redux
                const tasks = useSelector(state => state.tasks);

                // Na bazie stausu Redux otrzymujemy dane pochodne
                const count = tasks.reduce(
                    (acc, task) => {
                        if (task.completed) {
                            acc.completed += 1;
                        } else {
                            acc.active += 1;
                        }
                        return acc;
                    },
                    { active: 0, completed: 0 }
                );

                return (
                    <div>
                        <p>Active: {count.active}</p>
                        <p>Completed: {count.completed}</p>
                    </div>
                );
            };
          

6.5 Funkcje selektory

Ten sam selektor można wykorzystywać w kilku miejscach aplikacji, co prowadzi do dublowania kodu, jak na przykład w naszych komponentach TaskList, StatusFilter i TaskCounter. Aby tego uniknąć i jeszcze bardziej ustrukturyzować kod, wszystkie funkcje selektory deklaruje się w oddzielnym pliku, na przykład w src/redux/selectors.js, po czym są importowane do komponentów.

            src/redux/selectors.js
            export const getTasks = state => state.tasks;
            export const getStatusFilter = state => state.filters.status;
          

JEDNO ŹRÓDŁO PRAWDY: Deklarowanie funkcji selektorów wewnątrz komponentów jest dobre również dlatego, że komponenty nie wiedzą o kształcie statusu Redux, i w przypadku jego zmiany wystarczy zredagowanie kodu jednego pliku, a nie szukanie selektorów po kodzie wszystkich komponentów aplikacji.

6.6 Menadżer zadań

Przeanalizuj żywy przykład naszej aplikacji. W tym momencie w aplikacji realizowana jest inicjalizacja store z narzędziami programisty i subskrypcji komponentów na store. Następnym krokiem będzie dodanie wysłania akcji.

codesandbox.io

7.1 Akcje (actions)

Akcje - to obiekty, które przekazują dane z komponentów do store, tym samym sygnalizując to, jakie zdarzenie zaszło w interfejsie. Są to jedyne źródła informacji dla store.

            const action = {
                type: "Action type",
                payload: "Payload value",
            };
          

Akcje powinny mieć obowiązkową właściwość type - łańcuch, który opisuje typ zdarzenia w interfejsie. Mimo właściwości type struktura obiektu może być dowolna, niemniej jednak dane zazwyczaj przekazywane są w nieobowiązkowej właściwości payload. Danymi akcji może być dowolna wartość oprócz funkcji i klas.

Utworzymy akcje, które będą opisywać dodanie, usunięcie i przełączenie statusu zadania, a także zmianę wartości filtra.

            const addTask = {
                type: "tasks/addTask",
                payload: {
                    id: "Generated id",
                    text: "User entered text",
                    completed: false,
                },
            };

            const deleteTask = {
                type: "tasks/deleteTask",
                payload: "Task id",
            };

            const toggleCompleted = {
                type: "tasks/toggleCompleted",
                payload: "Task id",
            };

            const setStatusFilter = {
                type: "filters/setStatusFilter",
                payload: "Filter value",
            };
          

DOBRE PRAKTYKI - NAZEWNICTWO: Jedna z najpopularniejszych konwencji utworzenia rodzaju akcji proponuje wykorzystanie w wartości pola type dwóch części w formacie domain/eventName. Pierwsza to nazwa kategorii (jednostki), do której należy akcja (tasks i filters), zazwyczaj pokrywa się z nazwą właściwości części statusu Redux i druga - zdarzenie, które opisuje akcję (addTask, deleteTask, toggleCompleted, setStatusFilter).

DOBRE PRAKTYKI - MINIMALIZM: Akcje powinny zawierać w sobie minimalny niezbędny zestaw informacji, wystarczający do zmiany statusu. Na przykład, przy usunięciu zadania wystarczy przekazać jego identyfikator, a nie ogólnie cały obiekt zadania.

7.2 Generatory akcji

Akcje to statyczne obiekty, wartość właściwości payload, których nie można wprowadzić dynamicznie. Generatory akcji (Action Creators) - funkcje, które mogą przyjmować argumenty, po czym tworzą i zwracają akcje z jednakowymi wartościami właściwości type, ale różnymi payload. Mogą mieć efekty uboczne, na przykład wypełniać właściwości domyślnie lub generować unikalny identyfikator dla obiektu zadania. Stworzymy generatory akcji dla naszej aplikacji.

            src/redux/actions.js
            import { nanoid } from "nanoid";

            export const addTask = text => {
              return {
                type: "tasks/addTask",
                payload: {
                  id: nanoid(),
                  completed: false,
                  text,
                },
              };
            };

            export const deleteTask = taskId => {
              return {
                type: "tasks/deleteTask",
                payload: taskId,
              };
            };

            export const toggleCompleted = taskId => {
              return {
                type: "tasks/toggleCompleted",
                payload: taskId,
              };
            };

            export const setStatusFilter = value => {
              return {
                type: "filters/setStatusFilter",
                payload: value,
              };
            };
          

UNIKALNY IDENTYFIKATOR ZADANIA: Zwróć uwagę na generator akcji utworzenia zadania addTask. W przyszłości przyswojeniem identyfikatora będzie zajmował się back-end, a tymczasem zrobimy to w naszym kodzie. W tym celu wykorzystamy bibliotekę nanoid.

7.3 Wysyłanie akcji

Aby poinformować store o tym, że w interfejsie zaszło jakieś zdarzenie, należy wysłać akcję. W tym celu w bibliotece React Redux znajduje się hook [useDispatch()](https://react-redux.js.org/api/hooks#usedispatch), który zwraca odnośnik do funkcji wysyłania akcji dispatch z obiektu utworzonego przez nas wcześniej store Redux.

            // Importujemy hook
            import { useDispatch } from "react-redux";

            const MyComponent = () => {
              // Otrzymujemy odnośnik do funkcji wysyłania akcji
              const dispatch = useDispatch();
            };
          

Dodajemy kod wysłania wcześniej zaprojektowanych akcji z komponentów naszej aplikacji. Aby skupić uwagę na wysyłaniu akcji, w przykładach pominiemy stylizację. Pełen kod aplikacji możesz przeanalizować na żywym przykładzie na końcu tej sekcji.

Utworzenie zadania

Przy submicie formularza w komponencie TaskForm należy wysłać akcję utworzenia nowego zadania, przekazując do niego wartość wprowadzoną przez użytkownika w pole tekstowe.

            src/components/TaskForm/TaskForm.js
            // Importujemy hook
            import { useDispatch } from "react-redux";
            // Importujemy generator akcji 
            import { addTask } from "../../redux/actions";

            export const TaskForm = () => {
              // Otrzymujemy odnośnik do funkcji wysyłania
              const dispatch = useDispatch();

              const handleSubmit = event => {
                event.preventDefault();
                const form = event.target;
                // Wywołujemy generator akcji i przekazujemy tekst zadania dla payload
                // Wysyłamy wynik - akcję utworzenia zadania
                dispatch(addTask(form.elements.text.value));
                form.reset();
              };

              return (
                <form onSubmit={handleSubmit}>
                  <input type="text" name="text" placeholder="Enter task text..." />
                  <button type="submit">Add task</button>
                </form>
              );
            };
          

Przy submicie formularza, na liście akcji po lewej stronie Redux DevTools, dodaje się wysłaną akcję utworzenia zadania. Po kliknięciu na nią i wybranie w prawej części zakładki Actions, można zobaczyć szczegółowe informacje.

Usuwanie zadania

Po kliknięciu na przycisk w komponencie Task, należy wysłać akcję usunięcia zadania, przekazując do niego identyfikator zadania. Te dane wystarczą do usunięcia zadania z tablicy obiektów.

            // Importujemy hook 
            import { useDispatch } from "react-redux";
            // Importujemy generator akcji
            import { deleteTask } from "../../redux/actions";

            export const Task = ({ task }) => {
              // Otrzymujemy odnośnik do funkcji wysłania akcji
              const dispatch = useDispatch();

              // Wywołujemy generator akcji i przekazujemy identyfikator zadania
              // Wysyłamy wynik - akcję usunięcia zadania
              const handleDelete = () => dispatch(deleteTask(task.id));

              return (
                <div>
                  <input type="checkbox" />
                  <p>{task.text}</p>
                  <button type="button" onClick={handleDelete}>
                    Delete
                  </button>
                </div>
              );
            };
          

Po kliknięciu na przycisk usunięcia, w Redux DevTools dodawana jest wysłana akcja usunięcia zadania. Po kliknięciu na nią można zobaczyć szczegółowe informacje.

Przełączenie statusu

Po kliknięciu na checkbox w komponencie Task, należy wysłać akcję przełączenia statusu zadania, przekazując do niej identyfikator zadania. Te dane wystarczą, aby znaleźć zadanie na tablicy obiektów i zmienić wartość właściwości na przeciwstawną.

           src/components/Task/Task.js 
          // Importujemy hook
          import { useDispatch } from "react-redux";
          // Importujemy generator akcji
          import { deleteTask, toggleCompleted } from "../../redux/actions";

          export const Task = ({ task }) => {
            // Otrzymujemy odnośnik do funkcji wysyłania akcji
            const dispatch = useDispatch();

            const handleDelete = () => dispatch(deleteTask(task.id));

            // Wywołujemy generator akcji i przekazujemy identyfikator zadania
            // Wysyłamy wynik - akcję przełączania statusu zadania
            const handleToggle = () => dispatch(toggleCompleted(task.id));

            return (
              <div>
                <input type="checkbox" onChange={handleToggle} checked={task.completed} />
                <p>{task.text}</p>
                <button onClick={handleDelete}>Delete</button>
              </div>
            );
          };
          

Po kliknięciu na checkbox w Redux DevTools dodawana jest wysłana akcja zmiany statusu zadania. Po kliknięciu na nią, można zobaczyć szczegółowe informacje.

BEZ ZBĘDNYCH PROPSÓW: Zwróć uwagę na to, że w komponencie Task brak dodatkowych propsów, na przykład metod do usuwania i zmieniania statusu, jak przy wykorzystania statusu React. To także czyni komponent listy zadań prostszym, nie musi przyjmować niepotrzebnych propsów i przerzucać ich do komponentu zadania. Wykorzystując Redux, dowolny komponent może bezpośrednio otrzymać dostęp do funkcji wysyłania akcji.

Zmiana filtra

Po kliknięciu na przycisk w komponencie StatusFilter należy wysłać akcję zmiany filtra, przekazując mu nową wartość. Wykorzystujemy obiekt wartości filtra z pliku konstant.

            src/components/StatusFilter/StatusFilter.js
            // Importujemy hook
            import { useSelector, useDispatch } from "react-redux";
            // Importujemy generator akcji
            import { setStatusFilter } from "../../redux/actions";
            // Importujemy obiekt wartości filtra
            import { statusFilters } from "../../redux/constants";

            export const StatusFilter = () => {
              // Otrzymujemy odnośnik do funkcji wysyłania akcji
              const dispatch = useDispatch();

              const filter = useSelector(state => state.statusFilter);

              // Wywołujemy generator akcji i przekazujemy wartość filtra
              // Wysyłamy wynik - akcja zmiany filtra
              const handleFilterChange = filter => dispatch(setStatusFilter(filter));

              return (
                <div>
                  <Button
                    selected={filter === statusFilters.all}
                    onClick={() => handleFilterChange(statusFilters.all)}
                  >
                    All
                  </Button>
                  <Button
                    selected={filter === statusFilters.active}
                    onClick={() => handleFilterChange(statusFilters.active)}
                  >
                    Active
                  </Button>
                  <Button
                    selected={filter === statusFilters.completed}
                    onClick={() => handleFilterChange(statusFilters.completed)}
                  >
                    Completed
                  </Button>
                </div>
              );
            };
          

Po kliknięciu na przycisk filtra, w Redux DevTools dodawana jest wysłana akcja zmiany filtra. Po kliknięciu na nią, można zobaczyć szczegółowe informacje.

7.4 Menadżer zadań

Przeanalizuj żywy przykład naszej aplikacji. W tym momencie w aplikacji realizowana jest inicjalizacja store z narzędziami programisty, subskrypcja komponentów do store i wysyłanie akcji. Następnym krokiem będzie dodanie logiki aktulizacji statusu Redux przy pomocy funkcji-reducerów.

codesandbox.io

8.1 Reducery (reducers)

Zaprojektowaliśmy status aplikacji, związaliśmy komponenty i store, dodaliśmy wysyłanie akcji. Przyszedł czas na napisanie logiki zmiany statusu Redux.

Reducer - to funkcja, która przyjmuje bieżący status i akcje w postaci argumentów i zwraca nowy status. Reducer określa, jak zmienia się status aplikacji w odpowiedzi na akcje wysłane do store. Pamiętaj, że akcje opisują tylko to, co zaszło, a nie to, jak zmienia się status aplikacji.

            (state, action) => nextState
          

8.2 Root reducer

W aplikacji zawsze będzie tylko jeden root reducer, który należy przekazać w createStore przy tworzeniu store'a. Ten reducer odpowiada za opracowywanie wszystkich wysłanych akcji i obliczanie nowego statusu.

            src/redux/reducer.js
            import { statusFilters } from "./constants";

            const initialState = {
              tasks: [
                { id: 0, text: "Learn HTML and CSS", completed: true },
                { id: 1, text: "Get good at JavaScript", completed: true },
                { id: 2, text: "Master React", completed: false },
                { id: 3, text: "Discover Redux", completed: false },
                { id: 4, text: "Build amazing apps", completed: false },
              ],
              filters: {
                status: statusFilters.all,
              },
            };

            // Wykorzystujemy initialState jako domyślną wartość statusu
            export const rootReducer = (state = initialState, action) => {
              // Reducer rozróżnia akcje po wartości właściwości type
              switch (action.type) {
                // W zależności od typu akcji będzie się wykonywała inna logika default:
                  // Każdy reducer otrzymuje wszystkie akcje wysłane w store.
                  // Jeżeli reducer nie powinien opracowywać jakiegoś typu akcji,
                  // należy zwrócić istniejący status bez zmian.
                  return state;
              }
            };
          

STATUS POCZĄTKOWY: W trakcie inicjalizowania store (akcja @@INIT w Redux DevTools) do wszystkich reducerów w postaci wartości statusu przekazuje się undefined. Dlatego dla każdego reducera należy wskazać wartość domyślną dla parametru state, która stanie się początkowym statusem aplikacji.

Dodamy logikę opracowywania akcji zadania. Sprawdzamy, czy typ wysłanej akcji odpowiada łańcuchowi "tasks/addTask" i zwracamy nowy obiekt, zawierający cały status, nawet dla właściwości, które się nie zmieniły.

            src/redux/reducer.js
            import { statusFilters } from "./constants";

            const initialState = {
              tasks: [
                { id: 0, text: "Learn HTML and CSS", completed: true },
                { id: 1, text: "Get good at JavaScript", completed: true },
                { id: 2, text: "Master React", completed: false },
                { id: 3, text: "Discover Redux", completed: false },
                { id: 4, text: "Build amazing apps", completed: false },
              ],
              filters: {
                status: statusFilters.all,
              },
            };

            export const rootReducer = (state = initialState, action) => {
              // Reducer realizuje akcje po wartości właściwości type
              switch (action.type) {
                // W zależności od rodzaju akcji będzie się wykonywała inna logika
                case "tasks/addTask": {
                  // Należy zwrócić nowy obiekt statusu
                  return {
                    // w którym są wszystkie dane istniejącego statusu
                    ...state,
                    // i nowa tablica zadań
                    tasks: [
                      // w której są wszystkie istniejące zadania
                      ...state.tasks,
                      // i obiekt nowego zadania
                      action.payload,
                    ],
                  };
                }
                default:
                  // Każdy reducer otrzymuje wszystkie akcje wysłane do store.
                  // Jeśli reducer nie powinien opracowywać jakiegoś typu akcji,
                  // należy zwrócić istniejący status bez zmian.
                  return state;
              }
            };
          

NIEZMIENNOŚĆ STATUSU: Pisanie logiki aktualizacji statusu ręcznie nie jest najłatwiejszą pracą, dlatego losowa zmiana statusu w reducerach to rozpowszechniony błąd. W praktyce nie będziesz pisał skomplikowanych, zagnieżdżonych, niezmiennych aktualizacji ręcznie. Na następnych zajęciach dowiesz się, jak wykorzystywać Redux Toolkit, aby ułatwić sobie napisanie logiki aktualizacji statusu.

Kod pliku utworzenia store importuje i wykorzystuje root reducer.

            src/redux/store.js
            import { createStore } from "redux";
            import { devToolsEnhancer } from "@redux-devtools/extension";
            import { rootReducer } from "./reducer";

            const enhancer = devToolsEnhancer();
            export const store = createStore(rootReducer, enhancer);
          

AKTUALIZACJA INTERFEJSU: Jeżeli teraz spróbujemy dodać nowe zadanie w interfejsie naszej aplikacji, to na liście zadań wyświetli się nowy element. Rzecz w tym, że hook useSelector zmusza komponent do renderowania się powtórnie za każdym razem w przypadku zmiany tej części statusu, którą subskrybuje komponent.

8.3 Zasady reducerów

Reducery powinny być czystymi funkcjami i mają przestrzegać określonych zasad:

  • Nie można zmieniać argumentów (state i action). Reducery powinny jedynie obliczać nową wartość statusu na podstawie tych argumentów.
  • Nie można zmieniać istniejącego statusu (state). Zamiast tego reducery powinny robić aktualizacje, kopiując istniejący status i wnosząc zmiany do kopii.
  • Reducery nie powinny powodować żadnych "efektów ubocznych". Na przykład, uruchomienie timera, wykonanie zapytania HTTP, zmiana wartości wewnątrz funkcji lub jej argumentów, generowanie losowych liczb lub łańcuchów itd.

Jak realizować efekty uboczne przeanalizujemy dalej, na razie pamiętaj po prostu, że reducer powinien być czystą funkcją. Otrzymując argumenty, powinien obliczać następny status i zwracać go. Żadnych efektów ubocznych. Żadnych mutacji. Tylko obliczenie nowej wersji statusu.

8.4 Opracowywanie akcji

Dodajemy do root reducer kod opracowywania wszystkich pozostałych akcji naszej aplikacji.

Usunięcie zadania

W przypadku usuwania dostępny jest dla nas identyfikator zadania we właściwości payload, dlatego wykorzystujemy metodę Array.filter(), aby niezmiennie utworzyć nową tablicę bez tego zadania. Sprawdzamy, czy rodzaj wysłanej akcji odpowiada łańcuchowi "tasks/deleteTask" i zwracamy nowy obiekt statusu.

            src/redux/reducer.js
            export const rootReducer = (state = initialState, action) => {
              switch (action.type) {
                case "tasks/addTask":
                  return {
                    ...state,
                    tasks: [...state.tasks, action.payload],
                  };
                case "tasks/deleteTask":
                  return {
                    ...state,
                    tasks: state.tasks.filter(task => task.id !== action.payload),
                  };
                default:
                  return state;
              }
            };
          

Przełączenie statusu

W przypadku przełączenia statusu wystarczy identyfikator zadania we właściwości payload, dlatego wykorzystujemy metodę Array.map(), aby niezmiennie utworzyć nową tablicę ze zmiennymi wartościami właściwości completed w zadaniu z odpowiednimi identyfikatorami. Sprawdzamy, czy rodzaj wysłanej akcji odpowiada łańcuchowi "tasks/toggleCompleted" i zwracamy nowy obiekt statusu.

            src/redux/reducer.js
            export const rootReducer = (state = initialState, action) => {
              switch (action.type) {
                case 'tasks/addTask':
                  return { ...state, tasks: [...state.tasks, action.payload] };
                case 'tasks/deleteTask':
                  return { ...state, tasks: state.tasks.filter(task => task.id !== action.payload) };
                case 'tasks/toggleCompleted':
                  return {
                    ...state,
                    tasks: state.tasks.map(task => {
                      if (task.id !== action.payload) {
                        return task;
                      }
                      return { ...task, completed: !task.completed };
                    }),
                  };
                default:
                  return state;
              }
            };
          

Zmiana filtra

W przypadku zmiany filtra wystarczy nam nowa wartość filtra payload, dlatego sprawdzamy, czy typ wysłanej akcji odpowiada łańcuchowi "filters/setStatusFilter" i zwracamy obiekt statusu.

            src/redux/reducer.js
            export const rootReducer = (state = initialState, action) => {
              switch (action.type) {
                case "tasks/addTask":
                  return {
                    ...state,
                    tasks: [...state.tasks, action.payload],
                  };
                case "tasks/deleteTask":
                  return {
                    ...state,
                    tasks: state.tasks.filter(task => task.id !== action.payload),
                  };
                case "tasks/toggleCompleted":
                  return {
                    ...state,
                    tasks: state.tasks.map(task => {
                      if (task.id === action.payload) {
                        return {
                          ...task,
                          completed: !task.completed,
                        };
                      }
                      return task;
                    }),
                  };
                case "filters/setStatusFilter":
                  return {
                    ...state,
                    filters: {
                      ...state.filters,
                      status: action.payload,
                    },
                  };
                default:
                  return state;
              }
            };
          

Dodaliśmy kod opracowywania tylko czterech akcji, a kod root reducera już teraz bardzo się rozrósł. Jeżeli spróbujemy opracować wszystkie akcje aplikacji w jednej funkcji-reducerze, kod będzie dość trudny do zrozumienia. Dlatego reducery zazwyczaj dzielą się na kilka mniejszych, aby ułatwić rozumienie i wsparcie kodu.

8.5 Kompozycja reducerów

Reducery dzielą się zazwyczaj zależnie od części statusu Redux, które aktualizują. Podzielimy opracowywanie akcji zadań i zmiany filtra na dwa niezależne reducery. Każdy reducer będzie odpowiadał tylko za swoją część statusu Redux, dlatego kod aktualizacji statusu będzie znacznie prostszy.

            src/redux/reducer.js
            const tasksInitialState = [
              { id: 0, text: "Learn HTML and CSS", completed: true },
              { id: 1, text: "Get good at JavaScript", completed: true },
              { id: 2, text: "Master React", completed: false },
              { id: 3, text: "Discover Redux", completed: false },
              { id: 4, text: "Build amazing apps", completed: false },
            ];

            // Odpowiada za aktualizację właściwości tasks
            // Teraz wartością parametru state będzie tablica zadań
            const tasksReducer = (state = tasksInitialState, action) => {
              switch (action.type) {
                case "tasks/addTask":
                  return [...state, action.payload];
                case "tasks/deleteTask":
                  return state.filter(task => task.id !== action.payload);
                case "tasks/toggleCompleted":
                  return state.map(task => {
                    if (task.id !== action.payload) {
                      return task;
                    }
                    return { ...task, completed: !task.completed };
                  });
                default:
                  return state;
              }
            };

            const filtersInitialState = {
              status: statusFilters.all,
            };

            // Odpowiada jedynie za aktualizację właściwości filters
            // Teraz wartością parametru state będzie obiekt filtrów
            const filtersReducer = (state = filtersInitialState, action) => {
              switch (action.type) {
                case "filters/setStatusFilter":
                  return {
                    ...state,
                    status: action.payload,
                  };
                default:
                  return state;
              }
            };
          

Teraz mamy dwa oddzielne reducery, ale przy tworzeniu store należy przekazać jeden root reducer odpowiadający za cały status Redux. Możemy napisać root reducer tak, aby po prostu wywoływał dwa inne reducery i przekazywał im niewielką część statusu i akcji. To jest właśnie kompozycja reducerów.

            src/redux/reducer.js
            // Kod reducerów tasksReducer i filtersReducer

            export const rootReducer = (state = {}, action) => {
              // Zwracamy obiekt statusu
              return {
                // Obu reducerom przekazujemy tylko tę część statusu, za którą odpowiadają
                tasks: tasksReducer(state.tasks, action),
                filters: filtersReducer(state.filters, action),
              };
            };
          

Aby uniknąć tworzenia root reducera ręcznie, w bibliotece Redux istnieje funkcja combineReducers, która robi to samo, ale krócej.

            src/redux/reducer.js
            // Importujemy funkcję kompozycji reducerów
            import { combineReducers } from "redux";

            // Kod reducerów tasksReducer i filtersReducer

            export const rootReducer = combineReducers({
              tasks: tasksReducer,
              filters: filtersReducer,
            });
          

8.6 Menadżer zadań

Przeanalizuj cały żywy przykład naszej aplikacji.

codesandbox.io