Knižnica Redux

vzor/architektúra Flux, stavový kontajner, knižnica Redux, reducer funkcie, sklad, akcie

Záznam z prednášky

Miesto úvodu

  • (slide) je to neuveriteľné, ale stále pracujeme na baterke

    • baterka svieti
    • Dokonca vieme aj v prípade, že zariadenie baterkou nedisponuje, zobraziť správu a aplikáciu vypnúť. Dokonca to robíme už tesne po spustení aplikácie.
    • Pred prístupom k baterke vieme dokonca overiť aj to, či máme povolený prístup ku kamere na OS Android.
    • Máme ju dokonca pripravenú pre globálny trh, pretože sme vyriešili aj problematiku lokalizácie aplikácie do cudzieho jazyka.
    • Naposledy sme aplikáciu roztrhli na viac komponentov, takže miesto jedného, máme tri.
  • Dnes sa pozrieme na to, ako uchovávať stav aplikácie pomocou knižnice Redux.

Knižnica Redux

  • (slide) Redux je knižnica, ktorá slúži ako stavový kontajner pre vašu aplikáciu. Stavový kontajner drží celý stav vašej aplikácie a je označovaný názvom Single Source of Truth (jediný zdroj pravdy). To znamená, že jediný objekt, na ktorý sa budete dopytovať, ak budete potrebovať poznať aktuálny stav, bude práve stavový kontajner. A rovnako tak budete informácie v ňom považovať za jediné pravdivé. V aplikáciách sa takýto objekt zvykne označovať slovom store (sklad).

    Redux Logo
  • Použitie knižnice Redux, a obecne stavových kontajnerov, má veľký význam vo veľkých aplikáciách, ktoré sa skladajú z veľkého množstva komponentov, kedy môže byť riadenie stavu v priebehu času zložité. Rovnako sa stavové kontajnery používajú aj v prípade Single Page Application-s (SPA), kde sa dá stretnúť s použitím stavových kontajnerov aj ako jednoduchého databázového systému.

Redux a Flux

  • (slide) Ešte predtým, ako začneme hovoriť o knižnici Redux, pozrieme sa na architektúru Flux. Za touto architektúrou stojí spoločnosť Facebook a donedávna existovala ako samostatný projekt/knižnica. Ak sa však dnes pozriete na domovskú stránku projektu, tak nájdete oznámenie, že zdrojový kód Flux-u je v režime údržby s odkazom na “sofistikovanejšie” riešenia, akým je napríklad Redux. Redux je teda knižnica, ktorá je postavená na architektúre Flux.

  • Takže aby sme rozumeli, ako funguje knižnica Redux, pozrieme sa na architektúru Flux.

    Flux Logo

Architektúra Flux-u a jeho hlavné charakteristiky

  • (slide) Architektúra Flux je založená na návrhovom vzore pozorovateľ (z angl. observer). Hlavným komponentom tejto architektúry je dispečer. Ten pracuje ako istý rozbočovač pre všetky udalosti, ktoré môžu v systéme nastať. Udalosti v kontexte architektúry Flux nazývame akciami.

  • Hlavnou úlohou dispečera teda je:

    • prijímať notifikácie o vzniknutých udalostiach/akciách, a
    • posielať ich následne do všetkých skladov, ktorých môže byť viacero.
    Flux Architecture [@y30]
  • Sklad sa následne rozhodne, či ho prijatá akcia zaujíma alebo nie. V prípade záujmu sklad zmení svoj vnútorný stav (uložené údaje). Táto zmena sa následne stáva spúšťačom pre prekreslenie dotknutých pohľadov, ktoré sú v našom prípade reprezentované komponentmi React-u.

  • Akcie môžu prichádzať do dispečera z pohľadov (React komponentov), ale rovnako tak z ľubovoľných iných komponentov systému. Napr. modul zodpovedný za spracovanie HTTP požiadavky vyšle akciu, keď takúto požiadavku úspešne spracuje.

Architektúra Redux-u

  • (slide) Teraz sa pozrime na to, ako vyzerá architektúra Redux-u. Ako bolo uvedené, Redux je založený na Flux-e, takže architektúra bude veľmi podobná.

    Redux Architecture [@y30]
  • Komponenty aplikácie (blok React) vysielajú (dispatch) akciu. Akcia so sebou nesie informáciu o tom, čo sa stalo, ako napr.: bolo stlačené tlačidlo, vypršal časovač, vypadla sieť a pod. Akcia sa odosiela priamo do skladu, kde sa rozhodne, čo sa má na základe vzniknutej akcie vykonať. To, čo sa má vykonať, je uvedené v tzv. reducer-och. Reducer je “pure” funkcia, pomocou ktorej je možné zmeniť aktuálny stav aplikácie. Takže akonáhle sklad prijme akciu, požiada reducer-ov, aby na základe aktuálneho stavu a prijatej akcie vrátili nový stav. Následne je aktuálny stav doručený všetkým komponentom, ktoré oň majú záujem.

Rozdiely medzi architektúrami Redux a Flux

  • (slide) Tým hlavným rozdielom je to, že Redux používa len jeden zdroj pravdy (z angl. single source of truth) - tzv. sklad (z angl. store). V sklade sú uložené všetky údaje, ktoré vytvárajú stav vašej aplikácie. Jednotlivé komponenty v prípade zmeny stavu túto informáciu pošlú do skladu vo forme akcie (operácia s názvom dispatch) miesto toho, aby ju poslali zvlášť samostatným komponentom. Komponenty, ktoré majú záujem o dané údaje, sa prihlásia na ich odber zo skladu (operácia s názvom subscribe).

    Redux Store [@y31]
  • (slide) Sklad je v prípade Redux-u akýsi prostredník pre každú zmenu stavu v aplikácii. A vďaka Redux-u komponenty nekomunikujú medzi sebou priamo, ale cez sklad. Tým sa je možné vyhnúť aj prípadnému vzniku chýb, ktoré so sebou môžu niesť iné stratégie. V prípade Flux-u bol týmto prostredníkom dispečer.

    Error Prone and Confusing Strategies vs Redux [@y31]

Použitie knižnice Redux v aplikácii Torch

  • (slide) V nasledujúcej časti sa pokúsime implementovať Redux do našej aplikácie Torch.

  • Ako bolo uvedené, význam Redux-u je značný pri vytváraní veľkých aplikácií, čo rozhodne nie je prípad našej aplikácie. Takže v tomto prípade to bude len ukážka jeho použitia.

  • Ak však budete pátrať po odporúčaniach, ako používať Redux v aplikáciách React Native, resp. obecne ako najlepšie používať Redux (tzv. best practices), určite dôjdete aj ku téme štruktúra projektu. V našom prípade nemá táto téma veľké opodstatnenie, ale pre oddelenie vecí, ktoré súvisia s Redux-om od zvyšku aplikácie vytvoríme samostatný priečinok s názvom src/redux/. Do neho uložíme všetky súbory, ktoré budú s Redux-om súvisieť.

Príprava

  • Poďme teda pomaly aktualizovať baterku o Redux a pozrieme sa na jeho jednotlivé stavebné bloky zblízka.

  • Najprv samozrejme nainštalujeme samotný Redux. Je to otázka dvoch balíkov: redux a react-redux:

    $ yarn add redux react-redux
  • Následne v našej aplikácii:

    • odstránime každú zmienku o stave z aktuálnej aplikácie, pretože o stav sa momentálne bude starať sklad (Redux), a
    • odstránime vlastnosti z komponentov, pretože doteraz sme ich používali na získanie callback funkcie na volanie metódy u predka a teraz miesto ich volania budeme posielať skladu akciu
  • Konkrétne sa jedná o:

    • zrušenie hook-u useState() v komponente App
    • použitie komponentov ButtonSwitch a ImageSwitch bez vlastností v komponente App
    • odstránenie vlastností v komponentoch ButtonSwitch a ImageSwitch
  • Aby aplikácia aj napriek tomu fungovala, vytvoríme lokálnu premennú isOn a nastavíme ju na hodnotu false. Budeme ju používať do momentu, kým nezačneme používať sklad.

Creating the Store with Reducer

  • Začneme tým, že vytvoríme sklad, v ktorom sa bude nachádzať stav aplikácie. Keďže k nemu bude môcť pristúpiť ľubovoľná časť aplikácie, je dobrou praxou ho osamostatniť do osobitného súboru s názvom store.js, ktorý sa bude nachádzať v priečinku src/redux/.

  • Stav bude aj naďalej reprezentovaný stavovou premennou isOn ako doteraz a po spustení bude mať nastavenú hodnotu false.

  • Aby sme sklad mohli vytvoriť, potrebujeme vytvoriť aspoň jednu reducer funkciu.

  • Ako sme už hovorili, reducer je “pure” funkcia, takže na základe vstupu vždy vráti rovnaký výstup bez akýchkoľvek bočných následkov. To tiež znamená, že reducer by nemal volať asynchrónne funkcie.

  • Reducer dostane dva argumenty funkcie:

    • aktuálny stav, a
    • akciu, ktorá bola vyvolaná.
  • Reducer vždy vráti nový stav aplikácie.

  • V našom prípade si vystačíme s jedným reducer-om, ktorý uložíme do súboru so skladom. Pre náš prípad bude teda vyzerať takto:

    // in redux/store.js
    function torchReducer(state = {isOn: false}, action){
        return state;
    }
  • Zadefinovali sme akurát podobu počiatočného stavu, kde bude mať premenná isOn hodnotu false.

  • Teraz môžeme vytvoriť nový sklad:

    // in redux/store.js
    import { createStore } from "redux";
    
    export const store = createStore(torchReducer);

Subscribing to State Changes

  • Sklad je síce vytvorený, ale jednotlivé komponenty aplikácie zatiaľ nemajú prístup k jeho obsahu. Je teda potrebné zabezpečiť distribúciu zmeny stavu pre jednotlivé komponenty, ako aj umožniť komponentom aplikácie komunikovať so skladom prostredníctvom akcií.

  • Za týmto účelom je potrebné zabaliť hlavný komponent aplikácie do samostatného komponentu s názvom Provider. Jedinou vlastnosťou komponentu Provider je sklad, ktorý bude používať.

    // App.js
    import { Provider } from "react-redux";
    import { createStore } from "redux";
    import { store } from '../redux/store';
    
    export function App() {
        return (
            <Provider store={store}>
                <View style={styles.container}>
                    <ImageSwitch></ImageSwitch>
                    <ButtonSwitch></ButtonSwitch>
                </View>
            </Provider>
        );
    }
  • Komponent Provider nemá vizuálnu podobu. Zabezpečí však distribúciu zmien všetkým dotknutým komponentom (svojim potomkom), ktorí sa nachádzajú vo vnútri tohto komponentu.

  • (slide) To, čo ešte potrebujeme zabezpečiť, je prihlásenie (subscribe) komponentov na odoberanie akcií (udalostí) súvisiacich s aktualizovaním stavu aplikácie. Na prihlásenie sa komponentu k odberu akcie použijeme hook useSelector(). Tento hook umožní vybrať zo skladu požadované údaje pomocou výberovej funkcie (z angl. selector function), ktorá je parametrom tohto hook-u.

  • V prípade komponentu ButtonSwitch bude jeho použitie vyzerať takto:

    import { useSelector } from "react-redux";
    
    export function ButtonSwitch() {
        const { t } = useTranslation();
        const isOn = useSelector(function(state){
          return state.isOn;
        });
    
        return (
            <Button
                onPress={null}
                title={isOn === true ? 
                    t("Turn Off") : t("Turn On")}
            />
        );
    }
  • Podobne aktualizujeme aj komponent ImageSwitch:

    import { useSelector } from "react-redux";
    
    export function ImageSwitch() {
        const isOn = useSelector(function(state){
           return state.isOn;
        });
    
        const image = isOn
            ? require("../assets/bulb_on.png")
            : require("../assets/bulb_off.png");
    
        return (
            <Pressable onPress={null}>
                <Image source={image} />
            </Pressable>
        );
    }
  • Ak teraz spustíme aplikáciu, bude reagovať na zmenu stavu. Tú teraz vieme zmeniť iba natvrdo v počiatočnom stave z hodnoty false na true a opačne. Je však možné vidieť, že aplikácia sa naozaj zmení na základe hodnoty tohto stavu.

  • Ako je možné vidieť, aktuálnu hodnotu stavu nemáme od predka získanú pomocou vlastností (properties), ale priamo zo skladu pomocou hook-u useSelector().

Akcie

  • Takže aktualizácia aplikácie pomocou skladu nám už funguje. Teraz sa poďme pozrieť na to, ako vytvoriť akciu (udalosť), ktorá bude viesť k zmene stavu.

  • (slide) Obecne je možné akciu charakterizovať ako udalosť, ktorá nastala. V Redux-e je akcia reprezentovaná JSON objektom, ktorý takúto udalosť opisuje. Pre sklad predstavujú akcie jediný zdroj informácií.

  • (slide) Príklad takejto akcie je uvedený nižšie. Na ňom si akciu bližšie opíšeme.

    const action = {
        type: MOUSE_EVENT,
        x: 123,
        y: 456,
        button: MIDDLE_BUTTON,
        state: RELEASED
    };
  • Akcia v sebe nesie dva typy informácií:

    • typ akcie, ktorý je povinný, a
    • metaúdaje opisujúce alebo nesúce detaily akcie (vzniknutej udalosti).
  • Ak sa pozrieme na bežné udalosti, nájdeme jasné paralely. Napr. ak za udalosť pokladáme pohyb myšou (typ udalosti), tak jej metaúdaje budú napr. aktuálna poloha myši ([x, y]) a možno aj stav tlačítiek. Ak bol stlačený kláves (typ udalosti), tak metaúdaj bude určite obsahovať kód klávesy, resp. kláves, ktorý/é boli stlačené.

  • (slide) Aj napriek tomu, že naša aplikácia je pomerne jednoduchá, bude používať tieto tri typy akcií:

    • BUTTON_PRESSED - akcia vznikne po stlačení tlačidla,
    • IMAGE_PRESSED - akcia vznikne po stlačení obrázka, a
    • STATE_CHANGED - akcia vznikne pri reálnej zmene stavu (k zmene stavu totiž dôjsť nemusí okamžite po stlačení niektorého z dvoch tlačidiel, ak aplikácia nemá povolený prístup ku kamere).
  • Ako som už spomínal, akcia je objekt. Jediný kľúč, ktorý je povinný, je typ akcie type.

  • Akcia samotná nemusí so sebou niesť údaje, ale samozrejme môže. To je vlastne aj náš prípad, pretože akcie BUTTON_PRESSED a IMAGE_PRESSED údaje niesť nepotrebujú. Zato akcia STATE_CHANGED bude so sebou niesť nový stav.

Vytvorenie typov akcií

  • Typy akcií osamostatníme do osobitného súboru s názvom redux/actions.js. V ňom vytvoríme potrebné konštanty, ktoré budú reprezentovať jednotlivé typy akcií.

    // redux/actions.js
    export const BUTTON_PRESSED = 'BUTTON_PRESSED';
    export const IMAGE_PRESSED = 'IMAGE_PRESSED';
    export const STATE_CHANGED = 'STATE_CHANGED';
  • Tento prístup so sebou nesie niekoľko výhod:

    • nedôjde k zbytočnému preklepu, ak by bol typ akcie reprezentovaný reťazcom - to by mohlo mať za následok znefunkčnenie aplikácie,
    • zvýšenie ochrany pred preklepmi - k tomu nám samozrejme veľmi pomôže vývojové prostredie, ktoré bude názvom premenných/konštánt rozumieť,
    • možnosť zdokumentovať všetky typy akcií, ktoré môžu byť v aplikácii vytvorené, na jednom mieste,
    • získame podporu v nástrojoch pre vývojárov.

Vytvorenie objektov akcií

  • Vieme, čo akcie sú a máme zadefinované typy akcií. Teraz vytvoríme samotné objekty akcií, ktoré vložíme do príslušných komponentov, ktoré ich budú odosielať (dispatch):

    • akciu BUTTON_PRESSED vložíme do komponentu ButtonSwitch:

      import { BUTTON_PRESSED } from "../redux/actions";
      
      const action = {
          type: BUTTON_PRESSED
      }
    • akciu IMAGE_PRESSED vložíme do komponentu ImageSwitch:

      import { IMAGE_PRESSED } from "../redux/actions";
      
      const action = {
          type: IMAGE_PRESSED
      }
    • akciu STATE_CHANGED budeme posielať zvnútra funkcie onStateChange(), takže si ju zatiaľ pripravíme v komponente App:

      import { STATE_CHANGED } from "../redux/actions";
      
      const action = {
          type: STATE_CHANGED,
          isOn: null  // new state goes here
      }

Odoslanie akcie do skladu

  • (slide) Na odoslanie akcie z komponentu priamo do skladu použijeme funkciu dispatch(). Jej parametrom je akcia, ktorú chceme do skladu odoslať. Funkciu dispatch() získame pomocou hook-u s názvom useDispatch(), ktorý túto funkciu vráti.

  • Aktualizujeme teda kód komponentu ButtonSwitch tak, aby pomocou funkcie dispatch() poslal akciu uloženú v objekte action do skladu po stlačení tlačidla.

    import { useDispatch } from "react-redux";
    import { BUTTON_PRESSED } from "../redux/actions";
    
    export function ButtonSwitch() {
        const dispatch = useDispatch();
        const action = {
          type: BUTTON_PRESSED
      }
    
        return (
            <Button
                onPress={function() {
                  dispatch(action);
              }}
                title="Turn On"
            />
        );
    }
  • Rovnakým spôsobom môžem aktualizovať aj kód komponentu ImageSwitch:

    import { Image, Pressable } from "react-native";
    import React from "react";
    import { useDispatch, useSelector } from "react-redux";
    import { IMAGE_PRESSED } from "../redux/actions";
    
    export function ImageSwitch() {
      const isOn = useSelector(function(state) {
        return state.isOn;
      });
      const image = isOn == true 
          ? require("../assets/bulb_on.png") 
          : require("../assets/bulb_off.png");
    
      const action = {
        type: IMAGE_PRESSED
      };
    
      const dispatch = useDispatch();
    
      return (
        <Pressable onPress={function(){
          dispatch(action);
        }}>
          <Image source={image} />
        </Pressable>
      );
    }

Spracovanie akcie pomocou reducer funkcie

  • Po stlačení tlačidla bude doručená akcia typu BUTTON_PRESSED do skladu, kde sa stane argumentom reducer funkcií, v našom prípade funkcie torchReducer(). Tá je aktuálne prázdna, takže ju aktualizujeme tak, aby vedela zareagovať na každý typ akcie jeho zalogovaním:

    function torchReducer(state = { isOn: false }, action) {
        console.log(">> reducer invoked");
    
        switch (action.type) {
            case BUTTON_PRESSED:
                console.log(">> button pressed");
                return state;
    
            case IMAGE_PRESSED:
                console.log(">> image pressed");
                return state;
    
            case STATE_CHANGED:
                console.log(">> state changed");
                return state;
    
            default:
                console.warn(
                    `Action type "${action.type}" was not handled.`
                );
                return state;
        }
    }

Odoslanie a spracovanie akcie STATE_CHANGED

  • Akcie BUTTON_PRESSED a IMAGE_PRESSED nemenia stav aplikácie/skladu a v princípe sú to len kozmetické akcie pre ilustráciu práce s Redux-om. To ale nie je prípad akcie STATE_CHANGED, ktorá bude odoslaná v prípade skutočnej zmeny stavu. A to, či k zmene stavu dôjde alebo nie, overujeme vo volaní funkcie onStateChange(), ktorá sa nachádza v komponente App.

  • Ako už bolo uvedené skôr, reducer je “pure” funkcia. Z toho vyplýva, že ošetrenie akcie by malo byť veľmi rýchle a bez bočných efektov alebo volania asynchrónnych funkcií. Pre nás to znamená, že v reducer funkcii nebudeme volať objekt baterky, aby sa rozsvietil, prípadne zhasol. To totiž so sebou nesie niekoľko bočných funkcionalít, ako je asynchrónne volanie alebo overovanie prístupu ku kamere na platforme Android.

  • Aby sme túto kontrolu nevkladali do reducer funkcie torchReducer(), využijeme na to funkciu onStateChange(). V tejto funkcii overujeme, či má aplikácia dostatočné práva na prístup ku kamere a ak áno, tak rozsvietime baterku. Upravíme ju teda tak, že v prípade, že kontrola prebehne úspešne, tak okrem toho, že rozsvieti žiarovku, odošle do skladu aj akciu s novým stavom:

    if (cameraAllowed) {
        const state = store.getState();
        ding.play();
        await Torch.switchState(!state.isOn);
    
        store.dispatch({
            type: STATE_CHANGED,
            isOn: !state.isOn,
        });
    }
  • Túto funkciu budeme následne volať z oboch komponentov po odoslaní príslušnej akcie. Keďže sa však nachádza vo vnútri komponentu App a my ju potrebujeme volať z komponentov ButtonSwitch aj ImageSwitch, musíme ju z tohto komponentu vytiahnuť von. Vytvoríme preto samostatný modul helper.js a funkciu vložíme do neho s novým názvom toggleTorch().

    // helper.js
    const ding = new Sound(require("./assets/flashlight.wav"));
    
    export async function toggleTorch() {
      let cameraAllowed = true;
    
      if (Platform.OS === "android") {
        const granted = await PermissionsAndroid.request(
          PermissionsAndroid.PERMISSIONS.CAMERA,
          {
            title: i18n.t("Torch Needs Camera Permission"),
            message:
              i18n.t("Torch app uses camera flashlight as torch. " +
                     "To make it work, you need to allow Camera " +
                     "Permission."),
            buttonNeutral: i18n.t("Ask Me Later"),
            buttonNegative: i18n.t("Cancel"),
            buttonPositive: i18n.t("OK"),
          },
        );
    
        cameraAllowed = 
          (granted == PermissionsAndroid.RESULTS.GRANTED);
      }
    
      if (cameraAllowed) {
        const state = store.getState();
        ding.play();
        await Torch.switchState(!state.isOn);
    
        store.dispatch({
          type: STATE_CHANGED,
          isOn: !state.isOn
        });
      }
    }
  • Následne teda môžeme aktualizovať oba komponenty, kde zavoláme funkciu toggleTorch() po stlačení tlačidla:

    // components/ButtonSwitch.js
    import { toggleTorch } from "../helper";
    
    return (
        <Button
            onPress={function () {
                dispatch(action);
                toggleTorch();
            }}
            title={isOn === true ? t("Turn Off") : t("Turn On")}
        />
    );
  • Nakoniec už len upravíme príslušnú vetvu v reducer funkcii, kde vytvoríme nový stav na základe hodnoty získanej z akcie:

    
    case STATE_CHANGED:
        console.log(">> state changed");
        return {
            ...state,
            isOn: action.isOn,
        };
  • Ak teraz aplikáciu vyskúšame, bude fungovať tak, ako má - po stlačení tlačidla dôjde nie len k rozsvieteniu blesku na zariadení, ale aj k zmene stavu komponentov na základe aktuálneho stavu v sklade.

Conclusion

  • Dnes sme si predstavili architektúru Redux, pomocou ktorej sme uchovali stav našej aplikácie. Ten sme doteraz uchovávali pomocou hook-u useState(). Použitie Redux-u na malých projektoch nemá veľký význam, ale oceníte ho najmä vtedy, ak vytvárate veľké a komplexné aplikácie.

  • Nabudúce nadviažeme na túto tému a pozrieme sa na ďalšie možnosti perzistencie údajov vo vašich aplikáciách.