Redux: zaawansowane selektory

Redux: zaawansowane selektory

Artykuł ukazuje, w jaki sposób można tworzyć zaawansowane selektory w bibliotece Redux z pomocą narzędzia Reselect oraz jak wpływa to na projekt stanu aplikacji.

wtorek, 3 kwietnia 2018

Informatyka

Zapraszam do lektury kolejnego artykułu poświęconego dwóm technologiom front-endowym: bibliotekom ReactJS i Redux. Dzisiejszy temat związany jest z zarządzaniem stanem aplikacji, a konkretniej z tym, jaką rolę pełnią w nim tzw. selektory. Zacznijmy od przypomnienia sobie, jaki model wspomnianego zarządzania stanem proponuje nam Redux:

Ilustracja
Działanie biblioteki Redux

Jeśli chcemy napisać aplikację wyłącznie z użyciem ReactJS, do dyspozycji będziemy mieli kilka narzędzi do zarządzania stanem komponentów i komunikacji między nimi, jednak będą to tylko narzędzia. To od nas zależy, jak ich użyjemy i jak nasze komponenty będą ostatecznie ze sobą rozmawiać. Jeśli nie przemyślimy sobie tego zagadnienia, z biegiem czasu możemy skończyć z sytuacją, gdzie "wszystko będzie gadać ze wszystkim", a my przestaniemy panować nad tym, co się dzieje. Redux próbuje rozwiązać ten problem poprzez wprowadzenie jasnego, jednokierunkowego przepływu informacji:

  1. stan aplikacji przechowywany jest w jednym miejscu, tzw. Redux Store,
  2. komponenty nie zarządzają swoim stanem, lecz są powiadamiane o zmianach wybranych fragmentów stanu w Redux Store,
  3. w razie zmiany stanu, Redux wyciąga potrzebne informacje ze store'a, aktualizuje propsy komponentu i wymusza jego ponowne wyrenderowanie,
  4. komponent (lub dowolna inna część aplikacji) może zainicjować zmianę stanu aplikacji poprzez wyemitowanie tzw. akcji zawierającej wszystkie potrzebne informacje,
  5. za wyliczenie nowego stanu aplikacji odpowiada specjalna funkcja, tzw. reducer, który na bazie aktualnego stanu oraz otrzymanej akcji wylicza nowy stan.

Ważne jest to, że celem Reduksa nie jest zmniejszenie ilości niezbędnego kodu do napisania. Zdarzają się sytuacje, gdzie Redux wymusi na nas napisanie większej ilości kodu niż implementacja tego samego zachowania "na piechotę". Wśród rzeczy, które musimy przemyśleć i zaplanować, są również selektory: funkcje, które wyciągają ze stanu interesujące nas dane, aby można je było przesłać do komponentów.

Przykładowy, prosty selektor

Sama idea selektorów nie jest skomplikowana. Wyobraźmy sobie, że mamy aplikację wyświetlającą listę zadań do zrobienia i nad tą listą wyświetlamy przycisk pozwalający na pokazanie tylko nieukończonych zadań. Komponent renderujący przycisk potrzebuje informacji o tym czy przycisk jest aktywny czy nie. Stwórzmy zatem selektor:

export const isIncompleteTaskFilterEnabled = state => state.todos.filters.incompleteTaskFilter

I tyle. Najprostsze selektory są funkcjami, które pod spodem ukrywają po prostu ścieżkę do określonego atrybutu w stanie aplikacji i nic więcej. Możemy teraz użyć selektora w mapStateToProps:

const FilterButton = props => (<div>
   <button 
        className={props.filterEnabled ? 'enabled' : 'disabled'}
        onClick={props.onFilterClick}>Pokaż nieukończone</button>
</div>)

const mapStateToProps = state => ({
   filterEnabled: isIncompleteTaskFilterEnabled(state)
})

const mapDispatchToProps = dispatch => ({
   onFilterClick: () => dispatch(changeIncompleteTaskFilterStatus())
})

export const FilterButtonContainer = connect(mapStateToProps)(FilterButton)

Złota zasada projektowania stanu

Prędzej czy później natrafimy na sytuację, kiedy struktura naszych danych trzymanych w Reduksie, która do tej pory świetnie nam się sprawdzała, okaże się nieodpowiednia dla kolejnego tworzonego komponentu. Prędzej czy później natrafimy na sytuację, kiedy struktura danych pobranych z jakiegoś REST-owego API będzie nieefektywna. Posiadane dane będziemy musieli jakoś przetworzyć, zanim będą mogły być użyte. Pierwszym pomysłem, jaki może nam przyjść do głowy (a przynajmniej mi przyszedł), było przechowywanie tej samej informacji w kilku miejscach stanu:

  • state.cache.items - tutaj trzymamy dane tak, jak zostały pobrane z zewnętrznego źródła,
  • state.foo.remappedItems - tutaj trzymamy przekonwertowane dane.

Niestety szybko okazało się, że nie jest to właściwa droga. Zmuszała ona albo do tworzenia skomplikowanych sekwencji akcji, które musiały być wyemitowane jedna po drugiej, albo do trudności ze spamiętaniem, co gdzie siedzi i co trzeba zaktualizować w razie jakiejś zmiany (problem zgodności wszystkich wariantów danych). Potwierdzają to również twórcy Reduksa, którzy dają nam złotą zasadę projektowania stanu aplikacji:

Należy dążyć do tego, aby stan aplikacji w Redux Store był znormalizowany.

Innymi słowy, jeśli jakąś informację możemy wyliczyć na podstawie tego, co już przechowujemy w stanie aplikacji, to nie powinniśmy jej do stanu w ogóle zapisywać, lecz właśnie wyliczać wtedy, gdy jest ona nam potrzebna. Gdzie jednak takie transformacje powinno się robić? Idealnym miejscem są selektory, jednak pojawia się kolejny problem, jak robić to wydajnie i nie przeliczać na nowo tych samych danych, jeśli źródłowy stan się nie zmienił. Właśnie tu do akcji wkracza biblioteka reselect.

Biblioteka Reselect

Reselect umożliwia nam tworzenie bardziej złożonych, a jednocześnie wciąż wydajnych selektorów, które nie tylko wyciągają jakieś informacje ze stanu, lecz także wykonują na nich jakieś obliczenia. Poniższy zmodyfikowany rysunek ukazuje miejsce tego narzędzia w cyklu przepływu:

Ilustracja
Miejsce biblioteki reselect

Działanie takich selektorów jest bardzo proste: sprawdzamy czy źródłowe fragmenty stanu aplikacji uległy zmianie i jeśli tak, to wykonujemy transformacje i zwracamy wynik, a jeśli nie - od razu zwracamy wcześniej wyliczoną wersję. Reselect sam zajmuje się obsługą cache'owania wyniku obliczeń oraz wykrycia zmian. Spróbujmy napisać praktyczny selektor dla wspomnianej już wcześniej aplikacji zarządzania zadaniami, który wylicza parametry żądania HTTP na podstawie informacji o filtrach i tego, którą stronę wyników aktualnie wyświetlamy:

const getTaskFilters = state => state.todos.filters
const getPaginationSettings = state => state.todos.pagination

const toQueryString = params => Object.keys(params)
    .reduce((queryStringParts, nextKey) => ([...queryStringParts, nextKey + '=' +params[nextKey]]), [])
    .join('&')

const computeQueryParams = (filters, pagination) => toQueryString({
    filters: Object.keys(filters).filter(name => filters[name]).join(',')
    page: pagination.currentPage,
    pageSize: pagination.pageSize
})

export const getTaskQueryParams = createSelector(
    getTaskFilters,
    getPaginationSettings,
    computeQueryParams
)

createSelector() generuje właściwą funkcję selektora. Przyjmuje ona N argumentów, z czego N-1 to inne selektory, których zadaniem jest wyciągnięcie stanu źródłowego. Ostatnim argumentem jest funkcja, w której wykonujemy właściwe obliczenia, która będzie wywołana, jeśli stan źródłowy ulegnie zmianie. Wszystkie elementy stanu źródłowego będą przekazane do niej jako kolejne argumenty wywołania tak, jak pokazano na powyższym przykładzie. Nasze selektor śledzi zmiany dwóch fragmentów stanu reprezentowanych przez proste selektory getTaskFilters oraz getPaginationSettings. Wyniki ich działania są wrzucane odpowiednio jako pierwszy i drugi argument do funkcji computeQueryParams, która na ich podstawie wylicza query string.

Parametryzacja

Funkcja createSelector() nie przewiduje parametryzacji. Jedynym argumentem wyprodukowanego przez nią selektora może być obiekt state. Jest to celowa decyzja projektowa, która ma też swoje uzasadnienie związane z wydajnością. Biblioteka reselect() posiada własny cache do przechowywania wyliczonych danych i gdyby do tego wmieszać jeszcze dodatkowe argumenty, unieważnianie starych wpisów mogłoby być co najmniej trudne. Odradzam także próby obchodzenia "problemu" w sposób podobny do poniższego:

// konstrukcja potencjalnie niebezpieczna
export const selectSomething = someArgument => createSelector(
    firstSimpleSelector,
    secondSimpleSelector,
    computingFunc(someArgument)
)

// a tak nigdy nie rób :)
selectSomething(42)(state)

O ile samo utworzenie selektora tak, jak powyżej, może mieć jeszcze uzasadnienie, to zaprezentowany na przykładzie sposób wywołania jest błędny. Dlaczego? Otóż przy każdym wywołaniu będziemy tworzyć nową funkcję selektora, która będzie także przy każdym wywołaniu wykonywała obliczenia. Tracimy więc najważniejszą zaletę biblioteki reselect, jaką jest cache'owanie wyników obliczeń. Jej twórcy są zdania, że jeśli czujemy potrzebę parametryzacji takiego selektora, oznacza to, że nasze parametry tak naprawdę powinny być częścią stanu aplikacji. Powinniśmy zatem umieścić je właśnie tam oraz napisać dla nich akcję oraz reduktor:

const MY_SELECTOR_SETUP = 'my-selector-setup'

export const mySelectorSetupAction = someArgument => ({type: MY_SELECTOR_SETUP, someArgument})

export const mySelectorReducer = (state = {someArgument: false}, action) => {
    if (action.type === MY_SELECTOR_SETUP) {
        return {...state, someArgument: action.someArgument}
    }
    return state
}

export const getSomeArgument = state => state.someArgument

export const selectSomething = createSelector(
    firstSimpleSelector,
    secondSimpleSelector,
    getSomeArgument,
    computingFunc
)

W powyższym przykładzie wyeliminowaliśmy konieczność parametryzowania selektora, gdyż wartość parametru umieściliśmy w stanie i aktualizujemy go poprzez wyemitowanie odpowiedniej akcji. Nasz dotychczasowy argument stał się teraz po prostu jeszcze jednym prostym selektorem w wywołaniu createSelector().

Zakończenie

Selektory w bardziej złożonych aplikacjach opartych o bibliotekę Redux nie są użytecznym dodatkiem, lecz koniecznością. Zapraszam do zapoznania się z innymi możliwościami biblioteki reselect takimi, jak definiowanie własnej funkcji sprawdzającej zmianę stanu, a także z projektem reselect-map do przeprowadzania analogicznych obliczeń na kolekcjach elementów.

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Autor zdjęcia nagłówkowego: Garry Knight, CC-BY-2.0

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.