Strona główna / Artykuły / Jak React-Redux decyduje o ponownym renderowaniu: selektory, zasada równości i haki typowane.

Jak React-Redux decyduje o ponownym renderowaniu: selektory, zasada równości i haki typowane.

Przewodnik po mechanizmach wewnętrznych React-Redux: Provider, zasada równości useSelector, selektory zapisane w pamięci, funkcja connect(), haki typowane za pomocą withTypes() oraz rzadkie przypadki krawędziowe.

7738 słów

Powierzchnia React-Redux wydaje się niewielka: komponent provider i kilka hooków. Jednak w dużym kodzie te same pytania pojawiają się raz za razem. Dlaczego ten komponent jest renderowany przy każdym dispatchu? Dlaczego selektor, który zwraca obiekt, zachowuje się inaczej niż mapStateToProps? Kiedy shallowEqual jest odpowiednim narzędziem, co tak naprawdę opisują RootState i AppDispatch, oraz czy batch() jest nadal potrzebny w React 18?

To przewodnik odpowiada na te pytania, śledząc jeden wątek: co dzieje się między wysłanym działaniem a ponownym renderowaniem. Gdy ta ścieżka stanie się jasna, poszczególne API przestają być listą do zapamiętania i stają się częściami jednego systemu, który można analizować, debugować oraz wyjaśniać podczas przeglądu kodu lub rozmowy kwalifikacyjnej.

useSelector()
useDispatch()
<Provider />

Bieżące zalecenia: utrzymujący React-Redux polecają używanie API Hooks jako standardu dla komponentów. Funkcja connect() jest nadal obsługiwana i warto ją znać, ponieważ wiele istniejących kodów od niej zależy.

Czym jest React-Redux i jakie ma miejsce w ekosystemie

Redux zarządza stanem, uruchamia reduktory i powiadamia słuchacze; React renderuje interfejs użytkownika. React-Redux to oficjalne połączenie, które umożliwia komponentom odczytywanie danych z magazynu i wyzwalanie aktualizacji, podczas gdy React nadal odpowiada za renderowanie.

Redux
  │
  │ Application State
  ▼
React-Redux
  │
  │ Integration
  ▼
React
  │
  │ UI
  ▼
User

W praktyce to połączenie daje komponentom cztery możliwości:

  • odczytywanie wartości z stanu Redux
  • subskrybowanie się na zmiany tych wartości
  • wysyłanie działań do magazynu
  • powiązywanie aktualizacji magazynu z cyklem renderowania React

Współczesne punkty wejścia do tych operacji to trzy hooki:

useSelector()
useDispatch()
useStore()

ponadto komponent, który umożliwia dostęp do sklepu:

<Provider />

Dla starszego kodu istnieje również API komponentów wyższego rzędu, które nadal jest w pełni wspierane:

connect()

Redux i React-Redux to różne pakiety

Sam Redux jest kontenerem stanu i obejmuje następujące koncepcje:

Store
State
Actions
Reducers
Dispatch
Middleware
Selectors

React-Redux to jedynie most łączący, a wszystko, co eksportuje, dotyczy komunikacji z React:

Provider
useSelector
useDispatch
useStore
connect

Gdy są one ułożone jedna na drugiej, warstwy wyglądają w ten sposób:

              Redux
        ┌───────────────┐
        │ Store         │
        │ State         │
        │ Reducers      │
        │ Actions       │
        │ Middleware    │
        └───────┬───────┘
                │
                ▼
          React-Redux
        ┌───────────────┐
        │ Provider      │
        │ useSelector   │
        │ useDispatch   │
        │ useStore      │
        │ connect       │
        └───────┬───────┘
                │
                ▼
              React

Jeśli pytanie dotyczy sposobu zmiany stanu (reduktory, middleware, struktury akcji), należy je zaliczyć do Redux. Jeśli chodzi o to, kiedy komponent zaobserwuje zmianę lub się wyświetli, należy je zaliczyć do React-Redux.

Główny cykl, który należy mieć w pamięci

Zanim przejdziesz do jakiejkolwiek konkretnej API, zapamiętaj ten cykl: przeczytaj selektor, wywołaj odpowiednią akcję przy interakcji, zredukuj stan do nowej wartości, ponownie wywołaj selektor i wyświetl treść tylko wtedy, gdy zmieniła się wybrana wartość.

                 React Component
                       │
                       │ useSelector()
                       ▼
                 Redux Store
                       │
                       │ current state
                       ▼
                    Component
                       │
                       │ user interaction
                       ▼
                 useDispatch()
                       │
                       │ dispatch(action)
                       ▼
                    Reducer
                       │
                       ▼
                  New Redux State
                       │
                       ▼
                 useSelector()
                       │
                       ▼
                Re-render if
                 selected value
                   changed

Prawie każde pytanie dotyczące wydajności w React-Redux odnosi się do ostatniego kroku tego schematu.

Provider: umożliwienie dostępu do store’a

Hooks mogą komunikować się jedynie ze store’em, który znajduje się gdzieś wyżej w drzewie aplikacji. <Provider> jest tym elementem, który umieszcza go w odpowiednim miejscu. Zazwyczaj otaczasz korzeń aplikacji tym elementem tylko raz:

import { Provider } from 'react-redux'
import { store } from './store'
function AppRoot() {
  return (
    <Provider store={store}>
      <App />
    </Provider>
  )
}

W rzeczywistości Provider umieszcza store w kontekście React. Każdy potomek, bez względu na głębokość, może do niego uzyskać dostęp bez konieczności przechodzenia przez kolejne właściwości (prop drilling):

<Provider store={store}>
       │
       ├── App
       │    ├── Header
       │    ├── Dashboard
       │    ├── UserProfile
       │    └── Settings
       │
       └── Every descendant
            can use Redux

Jeśli komponent wywołuje jeden z hooków poza Providerem, nie ma żadnego magazynu danych do odczytu i hook zawodzi (w praktyce wywołuje błąd informujący o braku wartości kontekstu):

useSelector(...)
useDispatch(...)

Często zdarza się to w testach jednostkowych, które renderują komponent bez umieszczenia go wewnątrz testowego Providera.

useSelector: jak komponenty odczytują stan

useSelector() to hook, który będziesz używał najczęściej. Podajesz mu funkcję, która przyjmuje cały stan Redux i zwraca tę część, której potrzebuje komponent:

const count = useSelector(
  state => state.counter.value
)

Struktura przepływu danych jest prosta:

Redux State
     │
     ▼
useSelector()
     │
     ▼
Selected Value
     │
     ▼
React Component

Ponieważ React-Redux może wywoływać twój selector częściej, niż się spodziewasz (przy renderowaniu, po wysyłaniu zdarzeń, podczas sprawdzeń w czasie rozwoju), selector musi być czysty: te same dane wejściowe, te same dane wyjściowe, brak efektów ubocznych.

Co robi hook przy każdym renderowaniu i wysyłaniu zdarzeń

Weź selektor, który wybiera bieżącego użytkownika:

const user = useSelector(
  state => state.auth.user
)

Za tą jedną linią React-Redux wykonywa prosty proces:

  1. czyta aktualny stan sklepu
  2. wywołuje twój selektor z tym danymi
  3. przechowuje zwróconą wartość jako „ostatnio wybrany” wynik
  4. rejestruje subskrypcję do sklepu dla tego komponentu
  5. ponownie uruchamia selektor po każdej wysłanej akcji
  6. porównuje poprzedni wynik z nowym
  7. planuje ponowną renderizację tylko wtedy, gdy porównanie pokazuje różnicę

To właśnie krok 6 jest źródłem niemal wszystkich zaskakujących zachowań.

Domyślne porównanie to ścisła równość referencyjna

Domyślnie ten hook

useSelector()

porównuje wyniki za pomocą ===:

previousResult === newResult

Gdy porównanie daje

true

Selektor nie daje React-Redux żadnego powodu do ponownego renderowania komponentu. Gdy to nastąpi

false

komponent zostaje zaplanowany do ponownego renderowania.

To celowa różnica w porównaniu z connect(), który dokonuje powierzchownego porównania obiektu zwróconego przez mapStateToProps. Kod, który działał poprawnie przy użyciu connect(), może zacząć renderować się przy każdej akcji po przeniesieniu na hooks bez dostosowania selektora.

Dlaczego zwracanie nowego obiektu powoduje ponowne renderowanie za każdym razem

Pierwsza naturalna próba odczytania dwóch wartości wygląda tak:

const data = useSelector(state => ({
  user: state.user,
  count: state.count
}))

Ze samymi wartościami nie ma problemu, ale funkcja strzałkowa tworzy zupełnie nowy literał obiektu za każdym razem, gdy jest wykonywana:

{
  user: ...,
  count: ...
}

Dwa literały obiektów o identycznej zawartości to nadal dwa różne obiekty, więc ta sprawdzenie

oldObject === newObject

zawsze daje taki wynik

false

Skutkiem jest łańcuch, który uruchamia się przy każdej akcji w aplikacji, włączając w to akcje, które nie mają nic wspólnego z użytkownikami ani liczbami:

Action dispatched
      ↓
Selector executes
      ↓
New object created
      ↓
Different reference
      ↓
Component re-renders

Jest to jeden z najczęstszych błędów wydajnościowych w React-Redux. Dokumentacja wymienia trzy rozwiązania: oddzielne selektory, własną funkcję równości taką jak shallowEqual lub selektor zaimprowizowany.

Rozwiązanie pierwsze: wywołaj useSelector raz na każdą wartość

Zamiast łączyć wartości w jeden obiekt,

const data = useSelector(state => ({
  user: state.user,
  count: state.count
}))

rozdziel czytanie tych wartości na niezależne hooki:

const user = useSelector(
  state => state.user
)
const count = useSelector(
  state => state.count
)

Każdy selektor zwraca teraz wartość, która już znajduje się w store’ie, a nie nowy obiekt otaczający ją. Gdy stan przechowujący te wartości się nie zmienił

user unchanged
count unchanged

odniesienia są identyczne, więc sprawdzenie === kończy się pomyślnie.

Nie ma kary za wielokrotne wywołanie hooka w jednym komponencie. Jeśli pojedyncze wysyłanie zmienia więcej niż jedną z wybranych wartości, React-Redux grupuje powstałe aktualizacje, dzięki czemu komponent jest renderowany tylko raz na to wysyłanie, a nie raz na każdy hook.

Rozwiązanie drugie: shallowEqual dla celowych obiektów

Czasami najprostszym rozwiązaniem jest faktycznie zwrócenie obiektu, na przykład gdy komponent korzysta z małego zbioru powiązanych pól. W takim przypadku należy przekazać shallowEqual jako funkcję porównania:

import {
  useSelector,
  shallowEqual
} from 'react-redux';

const data = useSelector(
  state => ({
    user: state.user,
    count: state.count
  }),
  shallowEqual
)

Dzięki temu React-Redux porównuje każde pole najwyższego poziomu starego i nowego obiektu za pomocą ===, zamiast porównywać same referencje obiektów. Nowy obiekt zidentycznymi wartościami pól uważany jest za nienaruszony.

Nowsze wersje przyjmują również funkcję porównania poprzez obiekt opcji:

const data = useSelector(
  selector,
  {
    equalityFn: shallowEqual
  }
)

Kiedy shallowEqual jest przydatny

Użyj shallowEqual, gdy grupowanie wartości ułatwia czytanie komponentu. Nie traktuj tego jako standardowej funkcji stosowanej przy każdym wywołaniu:

useSelector(selector, shallowEqual)

Lepiej najpierw zastanowić się, czy komponent nie może po prostu wybrać każdej wartości osobno. W większości przypadków tak jest, a wynik jest łatwiejszy do przeglądania:

const user = useSelector(
  state => state.auth.user
)

const permissions = useSelector(
  state => state.auth.permissions
)

Pamiętaj, że shallowEqual sprawdza tylko jeden poziom głębokości. Jeśli pole w zwróconym obiekcie to samo jest świeżo utworzony tablica lub obiekt, porównanie nadal się nie powiedzie.

Selektorzy, które wyliczają dane

Selektorzy robią więcej niż tylko wybierają pola. To właśnie w nich obliczane są dane pochodne, takie jak lista filtrowana:

const selectCompletedTodos = state =>
  state.todos.filter(
    todo => todo.completed
  )

Problem polega na tym, że

filter()

zawsze alokuje nową tablicę. Nawet wtedy, gdy lista zadań nie uległa żadnej istotnej zmianie,

oldArray !== newArray

Zachowuje dane i komponent jest renderowany po każdej akcji. Memoizacja rozwiązuje ten problem poprzez przechowywanie w pamięci wyniku na podstawie danych wejściowych. Gdy dane wejściowe to te same obiekty co poprzednio, otrzymuje się z powrotem wynik z pamięci:

Same inputs
    ↓
Return cached result

Gdy dane wejściowe ulegną zmianie, obliczenia są wykonywane ponownie:

Changed inputs
    ↓
Recalculate

createSelector z Reselect

Standardowym narzędziem do tego celu jest funkcja createSelector z biblioteki Reselect (Redux Toolkit ją ponownie eksportuje). Wymienia się tu selektory danych wejściowych oraz funkcję powrotną, która jest wykonywana tylko wtedy, gdy te dane wejściowe ulegną zmianie:

import { createSelector } from 'reselect';

const selectCompletedTodos = createSelector(
  state => state.todos,
  todos =>
    todos.filter(todo => todo.completed)
)

Komponent korzysta z niej tak jak z każdego innego selektora:

const todos = useSelector(
  selectCompletedTodos
)

Pośrednio gdy state.todos to ta sama referencja tablicy, selektor zwraca jej wcześniej przefiltrowaną wersję, więc operacja === daje wynik prawdziwy. Należy jednak pamiętać, że selektor zaimmemoizowany przechowuje dane w pamięci, więc miejsce jego utworzenia ma znaczenie.

Stwórz selektory zapisane w pamięci, zawierające tylko stan, raz – na poziomie modułu

Gdy selektor zapisany w pamięci zależy wyłącznie od stanu Redux,

const selectCompletedTodos = createSelector(
  state => state.todos,
  todos => ...
)

deklaruj go poza jakimkolwiek komponentem:

const selectCompletedTodos = createSelector(...)

i odwołuj się do niego z komponentu:

function TodoList() {
  const todos = useSelector(
    selectCompletedTodos
  )
}

Jeśli funkcja createSelector zostanie wywołana wewnątrz ciała komponentu, przy każdym renderowaniu zostanie utworzony nowy selektor z pustą pamięcią cache, więc mechanizm memoizacji w ogóle nie będzie skuteczny. Instancja na poziomie modułu przetrwa wszystkie renderowania.

Selektory zapisane w pamięci, które potrzebują również propów

Zwykłe, niezapisane w pamięci selektory, które odczytują propy, nie stanowią problemu. Taki selektor nie przechowuje żadnej pamięci cache, więc nie może dojść do błędów:

function TodoListItem({ id }) {
  const todo = useSelector(
    state => state.todos[id]
  )
return <div>{todo.text}</div>
}

Sytuacja staje się bardziej złożona, gdy selektor zapisany w pamięci zależy od obu elementów – stanu i propów.

Redux State + Component Props

Taki selektor przechowuje wynik dla swoich ostatnich argumentów w pamięci cache. Jeśli wiele elementów listy dzieli się jedną instancją, przy czym każdy ma inny id, stale unieważniają wzajemnie swoją pamięć cache. W przypadku jednego komponentu zazwyczaj wystarczy utworzenie selektora za pomocą useMemo; przy wielu komponentach trzeba znać strategię memoizacji używaną w bibliotece (rozmiar cache lub jedna instancja na komponent). Kwestia ta pojawia się podczas rozmów rekrutacyjnych na wyższe stanowiska oraz przy obsłudze długich list.

Selektory muszą być czyste

Selektor powinien być zwykłą funkcją stanu:

State
  ↓
Selector
  ↓
Value

i nigdy nie może być miejscem, gdzie działania wyciekają na zewnątrz:

State
  ↓
Selector
  ↓
API call
  ↓
Mutation
  ↓
Side effect

Poniżej przedstawiono typ selektora, którego należy unikać; logowanie, żądania sieciowe oraz modyfikacje nie należą tu do zadań selektora:

const selectUser = state => {
  console.log('side effect')
  // API call ❌
  // mutation ❌
  return state.user
}

Używanie props wewnątrz selektora

Selektor zdefiniowany bezpośrednio w komponencie może po prostu korzystać z propsów tego komponentu:

function TodoItem({ id }) {
  const todo = useSelector(
    state => state.todos[id]
  )
return <div>{todo.text}</div>
}

Wartość przechodzi od właściwości do selektora poprzez zamykanie:

id
 ↓
closure
 ↓
selector
 ↓
state.todos[id]

To jest rzeczywista różnica w porównaniu z mapStateToProps, który otrzymuje ownProps jako drugi argument. useSelector() w ogóle nie przekazuje żadnych właściwości, więc trzeba polegać na zamykaniu lub na fabrykach selektorów, które przyjmują dodatkowe argumenty.

useDispatch: wysyłanie działań

Jeśli useSelector() odpowiada za odczyt,

useSelector
     ↓
    READ

to useDispatch() odpowiada za zapis:

useDispatch
     ↓
  DISPATCH

Zwraca funkcję dispatch sklepu, którą wywołuje się z obsługowników zdarzeń:

const dispatch = useDispatch()

function handleClick() {
  dispatch(increment())
}

lub bezpośrednio w JSX:

<button
  onClick={() => dispatch(increment())}
>
  Increment
</button>

Co uruchamia dispatch

Śledzenie procesu kliknięcia od początku do końca potwierdza podstawowy cykl opisany wcześniej:

User clicks button
       ↓
dispatch(action)
       ↓
Redux
       ↓
Reducer
       ↓
New state
       ↓
useSelector()
       ↓
Component updates

Konkretne wywołanie z twórcą akcji i treścią przekazywaną wygląda w ten sposób:

dispatch(
  addTodo({
    id: 1,
    text: 'Learn Redux'
  })
)

Stabilne funkcje zwrotne dla zapisanych w pamięci dzieci

Większość funkcji zwrotnych używanych do wysyłania zdarzeń nie wymaga useCallback. Wyjątek stanowią przypadki, gdy taka funkcja jest przekazywana

const increment = () =>
  dispatch(incrementAction())

dziecku, które jest otoczone funkcją React.memo:

<MyButton
  onIncrement={increment}
/>

Ponieważ funkcja strzałkowa jest tworzona na nowo przy każdym renderowaniu rodzica, zapisane w pamięci dziecko otrzymuje każdorazowo nową wartość propu i mimo to się renderuje. Otoczenie funkcji obsługi tym rozwiązaniem naprawia ten problem:

const increment = useCallback(
  () => dispatch(incrementAction()),
  [dispatch]
)

w połączeniu z dzieckiem zapisanym w pamięci:

const MyButton = React.memo(...)

Uwzględnienie dispatch jako zależności jest bezpieczne: jego tożsamość pozostaje niezmienna, dopóki Provider otrzymuje tę samą instancję sklepu.

useStore: bezpośredni dostęp, rzadko potrzebny

Trzeci hook przekazuje nam sam obiekt sklepu:

const store = useStore()

Dzięki niemu można wywołać surowe metody przechowywania:

store.getState()
store.dispatch(...)
store.subscribe(...)

Taki dostęp prawie nigdy nie powinien być używany przez komponent do renderowania danych. Czytanie odbywa się poprzez

useSelector()

a zapis odbywa się poprzez

useDispatch()

Dokumentacja traktuje useStore() jako rozwiązanie awaryjne w rzadkich przypadkach, takich jak wprowadzanie reduktora, a nie do codziennego czytania danych.

Dlaczego wartość z store.getState() w funkcji render jest przestarzała

Rozważmy komponent, który czyta bezpośrednio z przechowalnika:

function Component() {
  const store = useStore()
  const user = store.getState().user
  return <div>{user.name}</div>
}

To renderuje się poprawnie raz, a potem zostaje w tyle. getState() to jednorazowe odczytanie bez subskrypcji, więc późniejsze zmiany nigdy nie informują React o konieczności ponownego renderowania tego komponentu. Wersja z subskrypcją pozostaje zsynchronizowana:

const user = useSelector(
  state => state.user
)

Trzy hooki w pigułce

┌────────────────────────────┐
│ React-Redux Hooks          │
├────────────────────────────┤
│ useSelector()              │ → READ
│ useDispatch()              │ → DISPATCH
│ useStore()                 │ → STORE ACCESS
└────────────────────────────┘

W codziennym kodzie komponentów pierwsze dwa elementy wykonują praktycznie całą pracę:

90%+
useSelector()
useDispatch()

useStore() pojawia się tylko sporadycznie.

connect(): API komponentów wyższego rzędu

Hooks są zalecanym rozwiązaniem, ale connect() wciąż istnieje, a wiele długo funkcjonujących baz kodu jest na nim opartych. Można się spodziewać takiego kodu:

connect(
  mapStateToProps,
  mapDispatchToProps
)(Component)

Jak connect mapuje dane z store do propów

Model mentalny zakłada istnienie otulacza, który przekształca dane z store oraz funkcje dispatch w zwykłe propy:

Redux Store
     │
     ▼
connect()
     │
     ├── mapStateToProps
     │
     └── mapDispatchToProps
     │
     ▼
Component Props

mapStateToProps otrzymuje stan i zwraca obiekt propów:

const mapStateToProps = state => ({
  user: state.auth.user,
  count: state.counter.value
})

W praktyce dokonuje ono takiego przekształcenia:

Redux State
     ↓
Component Props

Komponent otoczony pozostaje zwykłą funkcją swoich propów i nie wie o istnieniu Reduxa:

function User({ user, count }) {
  return (
    <div>
      {user.name}
      {count}
    </div>
  )
}

mapDispatchToProps dostarcza funkcje zwrotne. W swojej wersji funkcyjnej otrzymuje się dispatch i samodzielnie tworzy się obsługi zdarzeń:

const mapDispatchToProps =
  dispatch => ({
    increment: () =>
      dispatch(increment())
  })

Następnie komponent wywołuje je jako właściwości:

props.increment()

Skrót obiektowy dla mapDispatchToProps

Bardziej zwięzła forma przekazuje obiekt twórców działań:

const mapDispatchToProps = {
  increment,
  decrement
}

React-Redux wiąże każdego twórcę działań, dzięki czemu wywołanie właściwości powoduje jego wykonanie. To zazwyczaj bardziej uporządkowana opcja.

Cztery powszechne formy łączenia

Zetkniesz się z czterema wariantami. Bez argumentów komponent otrzymuje jako właściwość tylko dispatch:

connect()(Component)

Gdy istnieje tylko maper stanu, komponent odczytuje dane, ale nie otrzymuje powiązanych twórców działań:

connect(
  mapStateToProps
)(Component)

Gdy pierwszym argumentem jest null, komponent nigdy nie słucha sklepu i otrzymuje jedynie właściwości dispatch:

connect(
  null,
  mapDispatchToProps
)(Component)

A gdy oba argumenty są podane, komponent czyta dane i wysyła je dalej:

connect(
  mapStateToProps,
  mapDispatchToProps
)(Component)

Wiedza o tym, że forma z null pomija subskrypcję, jest przydatna – to tani sposób na umożliwienie komponentowi dostępu do funkcji dispatch bez konieczności jego renderowania przy aktualizacjach sklepu.

connect zwraca nowy komponent

Wezwanie

connect(
  mapStateToProps,
  mapDispatchToProps
)(MyComponent)

nie modyfikuje MyComponent. Tworzy oddzielny komponent otaczający, który renderuje twój komponent w swoim wnętrzu:

MyComponent
     │
     ▼
connect()
     │
     ▼
ConnectedComponent

Dlatego moduły połączone z sklepem zazwyczaj eksportują wersję otoczoną jako domyślną, a czasami oddzielnie eksportują prosty komponent do testów.

Wybór między hookami a connect

Dla nowego aplikacji odpowiedź jest prosta:

New React application
        ↓
Hooks

Dla już istniejącego rozwiązania odpowiedź jest pragmatyczna:

Existing connect()
        ↓
Understand and maintain it

Haki oznaczają mniej kodu szablonowego, brak komponentów otulających oraz znacznie prostszy TypeScript, dlatego są standardem. Komponenty działające z connect() nie wymagają przepisywania; konwertuj je, gdy i tak się nimi zajmujesz.

Różnica w porównaniu równości w jednej linii

Pamiętaj o tym połączeniu:

useSelector()
      ↓
=== reference equality

versus

connect()
      ↓
shallow equality

Wiele błędów typu „działało przed refaktoryzacją” wynika z tego: świeży obiekt był bezpieczny w mapStateToProps, ale nie spełnia warunku === w useSelector().

Typowanie React-Redux za pomocą TypeScript

React-Redux dostarcza własne definicje typów, a dokumentacja opisuje standardowe ustawienie z typami. Opiera się ono na sześciu nazwach:

RootState
AppDispatch
AppStore
useAppSelector
useAppDispatch
useAppStore

RootState: wywnioskowanie typu stanu z magazynu

Gdy mamy sklep skonfigurowany z Redux Toolkit,

const store = configureStore({
  reducer: {
    counter: counterReducer,
    users: usersReducer
  }
})

wywodzimy typ stanu z tego, co zwraca getState:

export type RootState =
  ReturnType<typeof store.getState>

To jest lepsze niż ręczne definiowanie struktury,

type RootState = {
  counter: CounterState
  users: UsersState
}

ponieważ wywnioskowany typ automatycznie uwzględnia reduktory.

AppDispatch: typ dispatch z middleware

Wywnioskowujemy typ dispatch w ten sam sposób:

export type AppDispatch =
  typeof store.dispatch

Zwykły typ Redux Dispatch zna tylko proste akcje; ten wywnioskowany odzwierciedla Twoje middleware, co ma znaczenie, gdy używasz:

  • middleware, które zmienia to, co przyjmuje dispatch
  • thunków
  • sprawdzonego dispatchu
  • asynchronicznych akcji dowolnego typu

Bез AppDispatch wysyłanie thunka powoduje błąd typu.

AppStore: typ samego sklepu

Typ sklepu można wywnioskować jeszcze w jeden krok:

export type AppStore =
  typeof store

Dzięki temu każdy problem ma jedno źródło prawdy. Typ stanu:

RootState
    ↓
state type

Typ wysyłania zdarzeń:

AppDispatch
    ↓
dispatch type

Typ sklepu:

AppStore
    ↓
store type

AppStore okazuje się szczególnie przydatny, gdy tworzysz sklep na żądanie lub do testów i musisz go przekazywać dalej.

Haki z uprzednio zdefiniowanymi typami za pomocą withTypes()

Począwszy od React-Redux 9.1.0, każdy hak udostępnia metodę .withTypes():

useDispatch.withTypes()
useSelector.withTypes()
useStore.withTypes()

Dokumentowany wzorzec pozwala raz utworzyć haki specyficzne dla aplikacji na ich podstawie:

export const useAppDispatch =
  useDispatch.withTypes<AppDispatch>()

export const useAppSelector =
  useSelector.withTypes<RootState>()

export const useAppStore =
  useStore.withTypes<AppStore>()

Co oszczędzają haki typowane

Bez nich każdy selektor wymaga wyraźnej adnotacji:

const user = useSelector(
  (state: RootState) =>
    state.auth.user
)

Z hakiem typowanym,

const user = useAppSelector(
  state => state.auth.user
)

kompilator już wie

state = RootState

Część odpowiedzialna za wysyłanie zdarzeń działa w ten sam sposób:

const dispatch = useAppDispatch()

Ta funkcja dispatch przyjmuje thunks oraz wszystko inne, co pozwala middleware, z pełną weryfikacją.

Plik hooks.ts dla aplikacji

Zwykły projekt przechowuje je w jednym małym module:

import {
  useDispatch,
  useSelector,
  useStore
} from 'react-redux'

import type {
  RootState,
  AppDispatch,
  AppStore
} from './store'

export const useAppDispatch =
  useDispatch.withTypes<AppDispatch>()

export const useAppSelector =
  useSelector.withTypes<RootState>()

export const useAppStore =
  useStore.withTypes<AppStore>()

Komponenty importują z tego module zamiast bezpośrednio z react-redux:

const user = useAppSelector(
  state => state.auth.user
)

const dispatch = useAppDispatch()

ConnectedProps dla skompletowanego kodu connect()

Bazy kodu, w których komponenty connect() są skompletowane typowo, będą zawierać

ConnectedProps

Rozdziel funkcję connect najpierw na connector, a następnie wyodrębnij właściwości, które ten wprowadza:

const connector = connect(
  mapState,
  mapDispatch
)

type PropsFromRedux =
  ConnectedProps<typeof connector>

PropsFromRedux dokładnie opisuje, co wprowadza connector, dzięki czemu typy mapowane nigdy się nie powtarzają.

Projektowanie dobrych selektorów

Kuszące jest myślenie o selektorze jako o czymś więcej niż tylko

state => state.user

W większym aplikacji selektory lepiej traktować jako granicę, która przekształca format przechowywania stanu na formę wymaganą przez interfejs użytkownika:

Redux State
     ↓
Selector
     ↓
UI-friendly data

Na przykład reguła określająca, które zadania są widoczne, może znajdować się w jednej nazwanej funkcji:

const selectVisibleTodos =
  state =>
    state.todos.filter(
      todo => !todo.hidden
    )

a komponent po prostu żąda wyniku:

const todos = useSelector(
  selectVisibleTodos
)

Komponent pozostaje o charakterze prezentacyjnym, a reguła może być testowana osobno.

Dobry selektor jest precyzyjny

const selectUserName =
  state => state.auth.user.name

Zwraca dokładnie to, co wyświetla komponent, i nic więcej, więc uruchamia render tylko wtedy, gdy zmienia się ten nazwa.

Wybieranie całego stanu jest prawie zawsze błędne

const selectEverything =
  state => state

Redux tworzy nowy obiekt stanu korzeniowego za każdym razem, gdy jakiś reducer coś zmienia. Dlatego selektor zwracający stan korzeniowy dostarcza nową referencję praktycznie po każdej akcji:

Anything in Redux changes
        ↓
Root state reference changes
        ↓
Selector result changes
        ↓
Component re-renders

Weryfikacje rozwojowe React-Redux wykrywają ten wzorzec.

Zachowuj selektory szczegółowe

Niech lepiej będzie kilka precyzyjnych odczytów:

const count =
  useSelector(
    state => state.counter.value
  )

const user =
  useSelector(
    state => state.auth.currentUser
  )

zamiast jednego ogólnego odczytu:

const state =
  useSelector(state => state)

Praktyczna zasada:

Wybieraj najmniejszy fragment stanu, który komponent faktycznie może wykorzystać.

Weryfikacje selektorów w trybie rozwojowym

Nowsze wersje React-Redux przeprowadzają dodatkowe sprawdzenia selektorów podczas budowania w trybie rozwojowym. Dwa z nich warto znać pod względem nazwy.

Weryfikacja stabilności

Pierwsza weryfikacja wywołuje twój selektor po raz drugi z tym samym stanem i porównuje wyniki:

selector(state)
      ↓
run again with same state
      ↓
same result?

Jeśli rezultat jest

same reference

selektor jest stabilny. Jeśli nie jest

new reference

React-Redux wyświetla ostrzeżenie, ponieważ selektor, który zwraca nowy referencję dla identycznych danych wejściowych, będzie ponownie renderował swoją komponentę przy każdej aktualizacji sklepu.

Typowym problemem jest selektor w formie literalnego obiektu z poprzedniego przykładu:

const data = useSelector(
  state => ({
    count: state.count,
    user: state.user
  })
)

Ponieważ obiekt jest odbudowywany przy każdym wywołaniu, kontrola widzi:

same input
    ↓
different object
    ↓
unstable selector

Konfiguracja częstotliwości wykonywania kontroli

Możesz ustawić częstotliwość dla całej aplikacji w komponencie Provider:

<Provider
  store={store}
  stabilityCheck="always"
>
  <App />
</Provider>

lub ją przejąć dla pojedynczego wywołania hooka:

const count = useSelector(
  selectCount,
  {
    devModeChecks: {
      stabilityCheck: 'once'
    }
  }
)

Dozwolone wartości to:

never
once
always

Domyślną wartością jest 'once', co oznacza, że kontrola jest wykonywana przy pierwszym wywołaniu każdego hooka. Nic z tego nie działa w wersjach produkcyjnych.

Kontrola funkcji identyczności

Druga kontrola szuka selektora, który zwraca swoje dane wejściowe bez zmian:

state => state

W komponencie wygląda to tak:

const state = useSelector(
  state => state
)

To łączy komponent z każdą zmianą w store’u:

Any Redux change
       ↓
Root state changes
       ↓
Component re-renders

Dokumentacja nazywa to sprawdzeniem funkcji identyfikacyjnej. W wcześniejszych wersjach nazywano to noopCheck, co może jeszcze pojawić się w starszych konfiguracjach.

Rozwiązanie jest takie samo jak wcześniej: należy zastąpić

const state = useSelector(
  state => state
)

czytaniem konkretnych wartości, których potrzebujesz:

const count = useSelector(
  state => state.counter.value
)

const user = useSelector(
  state => state.auth.currentUser
)

Renderyzacja i wydajność poza selektorami

Renderyzacja rodzica nadal ma efekt kaskadowy

useSelector() reguluje jedynie renderyzacje spowodowane aktualizacjami store’u. Nie ma wpływu na zwykłą zasadę React, zgodnie z którą komponent jest renderowany, gdy jego rodzic jest renderowany:

Parent renders
      ↓
Child renders

Dzieje się tak nawet wtedy, gdy stan Redux w ogóle się nie zmienił. Gdy komponent potomek jest kosztowny, a jego props są stabilne, należy go otoczyć

React.memo()

To różni się od connect(), którego otoczka zachowuje się jak komponent z pamięcą podręczną; komponenty oparte na hookach nie otrzymują takiego zachowania za darmo.

Łączenie React.memo z useSelector

Tutaj komponent subskrybuje się na licznik i jest również zapisywany w pamięci podręcznej na podstawie swojej właściwości name:

const Counter = ({ name }) => {
  const count = useSelector(
    state => state.counter.value
  )

return (
    <div>
      {name}: {count}
    </div>
  )
}
export default React.memo(Counter)

Te dwa mechanizmy obejmują dwa źródła renderowania:

Redux selector
       +
React.memo
       ↓
More controlled rendering

Memoizacja ma swój własny koszt, więc najpierw przeprowadź profilowanie. Aby zapoznać się z szerszym wykazem powodów renderowania, sprawdź nasz przewodnik po częstych wzorcach powodujących niepotrzebne ponowne renderowanie w React.

Model wydajności pasujący na jeden ekran

Po każdej akcji React-Redux ponownie uruchamia selektory zainstalowanych komponentów, porównuje każdy wynik z poprzednim i renderuje tylko te komponenty, których wyniki się różnią:

Redux action
     ↓
Store updates
     ↓
Selectors execute
     ↓
Selector results compared
     ↓
Changed?
 ┌───┴────┐
No       Yes
│         │
│         ▼
│      Re-render
│
└── No Redux-triggered render

Panowa zadaniem jest zatem napisanie selektorów, które:

  1. zwracają tylko dane wykorzystywane przez komponent
  2. przekazują stabilne referencje, gdy nic istotnego się nie zmieniło
  3. nie tworzą nowych obiektów ani tablic bez konieczności
  4. memoizują dane pochodne, których obliczenie jest kosztowne

Zasada pierwsza: nigdy nie wybieraj stanu korzeniowego

useSelector(state => state)

Zasada druga: unikaj tworzenia obiektów otaczających w selektorach

Taki selektor tworzy nowe obiekty przy każdym uruchomieniu:

useSelector(state => ({
  user: state.user,
  count: state.count
}))

Zwracaj taki obiekt tylko wtedy, gdy celowo łączysz go z

shallowEqual

lub przekazujesz go przez memoizowany selektor.

Zasada trzecia: memoizuj kosztowne obliczenia pochodne

Sorтировanie, filtrowanie, grupowanie i łączenie należą do

createSelector(...)

Zasada czwarta: stosuj React.memo w odpowiednich miejscach

Zanim zaimplementujesz mechanizm memoizacji komponentu, przejdź przez krótką listę kontrolną:

Is the component expensive?
       ↓
Does it receive stable props?
       ↓
Does it re-render unnecessarily?
       ↓
Then consider React.memo()

Jeśli w twojej bazie kodu używany jest React Compiler, duża część tej ręcznej memoizacji może być już obsługiwana automatycznie.

Zasada piąta: wybieraj tak wąsko, jak to tylko możliwe

Zbyt szerokie:

state => state

Lepiej:

state => state.auth.user

Jeszcze lepiej, gdy komponent wyświetla tylko nazwę:

state => state.auth.user.name

Rzadkie przypadki krawędziowe: przestarzałe propy i „zombie” dzieci

Większość aplikacji nigdy nie napotyka tych dwóch problemów, ale one wyjaśniają, dlaczego używanie selektorów obronnych jest dobrym nawykiem.

Przestarzałe propy

Aby rozwiązać problem przestarzałych propów, potrzebny jest selektor zależny od danej właściwości oraz aktualizacja magazynu danych, która zmienia zarówno stan, jak i pośrednio tę właściwość:

Selector depends on component props
        ↓
Redux action updates state
        ↓
Parent would receive new props
        ↓
Child selector runs first
        ↓
Selector sees old props

Subskrypcja dziecka może zostać uruchomiona przed tym, jak rodzic ponownie wyrenderuje element i przekaże nową wartość propu. Przez chwilę selektor łączy aktualny stan z starym propem. Typowy przypadek wygląda tak:

const todo = useSelector(
  state => state.todos[props.id]
)

Jeśli element o danej id został właśnie usunięty lub rodzic zamierza przekazać inną id, selektor tymczasowo odczytuje dane, które już się nie odnoszą do danego elementu.

Pisanie selektorów tolerujących brakujące dane

Wersja wrażliwa zakłada, że element zawsze istnieje:

state.todos[props.id].name

Wersja obronna najpierw sprawdza, czy element istnieje,

const todo =
  state.todos[props.id]

a dopiero potem z niego odczytuje dane:

return todo
  ? todo.name
  : undefined

Decyzja polega na prostym warunku sprawdzającym obecność elementu:

Does data exist?
      ↓
Yes → use it
No  → handle safely

Łańcuchowanie opcjonalne (state.todos[id]?.name) wyraża tę samą ideę w jednym wyrażeniu.

Dzieci-zombie

Sytuacja z dzieckiem-zombim obejmuje rodzica, który wyświetla listę, oraz dziecko, które jest podpisane na jeden z elementów tej listy:

Parent
  │
  └── Child

Sekwencja wydarzeń wygląda następująco:

  1. Dziecko jest podpisane na zbiór danych.
  2. Jakaś akcja usuwa dane, które dziecko wyświetla.
  3. Podczas następnego renderowania rodzic przestanie wyświetlać to dziecko.
  4. Zanim to nastąpi, wykonywany jest proces podpisania dziecka.
  5. Selektor dziecka próbuje uzyskać dostęp do danych, które już nie istnieją.

W tym ostatnim kroku występuje błąd spowodowany niezabezpieczonym selektorem. React-Redux posiada mechanizmy na radzenie sobie z błędami selektorów powstałymi w wyniku aktualizacji zbioru danych, ale zabezpieczone selektory pozostają najskuteczniejszym rozwiązaniem.

Dlaczego hooks są bardziej podatne na błędy niż connect

connect() tworzy zagnieżdżony drzewo subskrypcji: każda skomponowana część aktualizuje się dopiero po tym, jak zrobią to jej przodkowie, co zapewnia porządek od góry do dołu. Hooki łączą się bezpośrednio z magazynem danych, bez takiej hierarchii, więc gwarancje dotyczące kolejności są słabsze, a te przypadki krawędziowe stają się teoretycznie możliwe.

Traktuj to jako wiedzę tło, a nie powód do unikania hooków. Oficjalna opinia brzmi, że takie problemy są rzadkie w rzeczywistych aplikacjach.

Zaawansowane funkcje Providera

Szyty na miarę kontekst dla izolowanych magazynów

Domyślnie,

<Provider store={store}>

wyświetla magazyn danych za pośrednictwem wbudowanego kontekstu React-Redux. Biblioteka komponentów wielokrotnie używalnych, która wewnętrznie wykorzystuje Redux, może w ten sposób kolidować z magazynem aplikacji gospodarza. Aby tego uniknąć, Provider przyjmuje własny kontekst:

<Provider
  context={MyContext}
  store={myStore}
>

Haki powiązane z tym kontekstem pochodzą z funkcji fabrycznych:

createStoreHook()
createDispatchHook()
createSelectorHook()

To ma znaczenie głównie w przypadku bibliotek wielokrotnego użycia, gdzie w przeciwnym razie kilka zasobów mogłoby się kolidować.

batch() i automatyczne grupowanie zdarzeń w React 18

Stary kod często otacza kolejne zdarzenia funkcją batch(), aby React renderował raz zamiast dwukrotnie:

batch(() => {
  dispatch(action1())
  dispatch(action2())
})

React 18 automatycznie grupuje aktualizacje, w tym te związane z obietnicami i terminatorami czasowymi, więc typowa aplikacja React 18 nie potrzebuje do tego funkcji batch(). Najnowsze wersje React-Redux zachowują tę funkcję głównie ze względów kompatybilnościowych; sprawdź aktualną dokumentację i spodziewaj się jej w starszym kodzie.

serverState do renderowania po stronie serwera i hydratacji

Dla renderowania po stronie serwera komponent Provider przyjmuje dodatkową właściwość:

<Provider
  store={store}
  serverState={preloadedState}
>

Serwer renderuje HTML z początkowego stanu i wysyła ten stan do przeglądarki. serverState sprawia, że proces renderowania po hydratacji wykorzystuje ten sam zrzut stanu, zapobiegając niezgodnościom:

Server
  ↓
Initial Redux State
  ↓
HTML
  ↓
Browser Hydration
  ↓
Provider(serverState)
  ↓
Consistent initial render

Typowy układ projektu

Aplikacja typu TypeScript zbudowana na Redux Toolkit jest często zorganizowana według funkcjonalności, przy czym łączenie sklepu danych znajduje się w jednym miejscu:

src/
│
├── app/
│   ├── store.ts
│   └── hooks.ts
│
├── features/
│   │
│   ├── counter/
│   │   ├── counterSlice.ts
│   │   └── Counter.tsx
│   │
│   ├── users/
│   │   ├── usersSlice.ts
│   │   └── Users.tsx
│   │
│   └── auth/
│       ├── authSlice.ts
│       └── Login.tsx
│
├── App.tsx
└── main.tsx

store.ts

Moduł sklepu danych konfiguruje reduktory i eksportuje wynikające z nich typy:

const store = configureStore({
  reducer: {
    counter: counterReducer,
    users: usersReducer,
    auth: authReducer
  }
})

export type RootState =
  ReturnType<typeof store.getState>

export type AppDispatch =
  typeof store.dispatch

export type AppStore =
  typeof store

hooks.ts

Moduł hooków przekształca te typy w hooki aplikacji:

export const useAppDispatch =
  useDispatch.withTypes<AppDispatch>()

export const useAppSelector =
  useSelector.withTypes<RootState>()

export const useAppStore =
  useStore.withTypes<AppStore>()

Komponent, który wykorzystuje oba

Komponent licznika odczytuje i zapisuje dane wyłącznie za pośrednictwem typowanych hooków:

function Counter() {
  const count = useAppSelector(
    state => state.counter.value
  )

const dispatch = useAppDispatch()
  return (
    <>
      <span>{count}</span>
      <button
        onClick={() =>
          dispatch(increment())
        }
      >
        +
      </button>
    </>
  )
}

Przepływ danych w tym komponencie jest dokładnie taki sam jak podstawowy cykl od początku:

Component
    │
    ├── useAppSelector()
    │        ↓
    │      READ
    │
    └── useAppDispatch()
             ↓
          DISPATCH
             ↓
           Redux
             ↓
          New State
             ↓
       useAppSelector()
             ↓
          Component

Gdzie pasuje Redux Toolkit

Redux Toolkit i React-Redux są uzupełnieniem siebie, a nie alternatywami:

Redux Toolkit
      +
React-Redux

Redux Toolkit ulepsza stronę Redux: konfigurację magazynu, reduktory, logikę asynchroniczną, selektory z memozacją oraz pobieranie danych.

configureStore
createSlice
createAsyncThunk
createSelector
RTK Query

React-Redux nadal odpowiada za połączenie z React:

Provider
useSelector
useDispatch
useStore
connect

Oficjalny szybki start z React-Redux konfiguruje oba narzędzia razem, a ta kombinacja jest standardem dla nowych projektów.

Pełny przepływ w jednym diagramie

Zbieranie wszystkich elementów razem, od komponentu Provider aż po sprawdzenie równości:

React
                   │
                   ▼
             <Provider>
                   │
                   ▼
             Redux Store
                   │
          ┌────────┴────────┐
          │                 │
    useSelector()      useDispatch()
          │                 │
          │                 ▼
          │               Action
          │                 │
          │                 ▼
          │              Reducer
          │                 │
          │                 ▼
          │             New State
          │                 │
          └─────────┬───────┘
                    ▼
              Selector runs
                    │
                    ▼
             Equality check
                    │
             ┌──────┴──────┐
             │             │
           Same         Different
             │             │
             ▼             ▼
         No Redux       Re-render
          render

Częste błędy i sposoby ich wykrycia

Subskrypcja na cały stan

useSelector(state => state)

Zastąp to specyficznymi selektorami.

Otoczanie wartości w nowy obiekt

useSelector(state => ({
  user: state.user
}))

Rozdziel to na oddzielne hooki lub celowo dodaj shallowEqual.

Filtrowanie lub mapowanie wewnątrz selektora przy każdym uruchomieniu

useSelector(state =>
  state.todos.filter(...)
)

Jeśli to działa często lub na dużych listach, przenieś to do selektora z pamięcą podręczną.

Czytanie danych renderowania za pomocą useStore

Wezwanie

store.getState()

w logice renderowania daje wartość bez subskrypcji. Użyj

useSelector()

aby komponent aktualizował się przy zmianie wartości.

Mutacja stanu bezpośrednio

Przypisanie w stylu

state.user.name = 'John'

łamie model niezmienności w Redux: referencje się nie zmieniają, więc selektory widzą „brak zmian” i komponenty się nie renderują. (W reduktorach createSlice z Redux Toolkit taki styl jest dozwolony, ponieważ Immer przekształca go w niezmienioną aktualizację; wszędzie indziej jest to błąd.)

Efekty uboczne wewnątrz selektorów

state => {
  fetch(...)
  return state.user
}

Selektor powinien obliczać i zwracać, nic więcej.

Memoizacja przez odruch

Rozmieszczanie ich wszędzie

useMemo()
useCallback()
React.memo()

zwiększa złożoność i obciążenie porównaniami bez dowodu na to, że przynosi korzyść. Optymalizuj render, który już zmierzyłeś.

Niewłaściwe umieszczanie zinstancjowanych selektorów

Selektor z pamięcią cache zachowuje się inaczej w zależności od swojego zakresu. Zanim go użyjesz, sprawdź, czy jest:

global
per component
per component instance

Wspólna instancja używana z wieloma różnymi argumentami może nigdy nie korzystać z pamięci cache.

Pytania z wywiadu o krótkich odpowiedziach

Czym jest React-Redux i dlaczego potrzebny jest Provider?

To oficjalne połączenie React z Redux: Provider, haki oraz connect umożliwiają komponentom odczytywanie stanu, reagowanie na zmiany i wysyłanie działań. Provider umieszcza sklep w kontekście React, dzięki czemu każdy potomek może do niego uzyskać dostęp.

useSelector kontra useDispatch?

Jeden służy do odczytu:

useSelector
    ↓
READ Redux state

Inny tekst:

useDispatch
    ↓
DISPATCH Redux actions

Jak useSelector powoduje ponowną renderizację?

Po każdej akcji ponownie uruchamia się selektor i porównuje wynik z poprzednim za pomocą === lub funkcji równości, którą podałeś. Tylko różnica powoduje uruchomienie renderizacji.

Dlaczego ten selektor ciągle powoduje ponowną renderizację i jak to naprawić?

useSelector(state => ({
  user: state.user,
  count: state.count
}))

Na każdym uruchomieniu tworzy się nowy obiekt, więc sprawdzenie referencji zawsze kończy się niepowodzeniem. Trzy rozwiązania:

1. Multiple useSelector calls

2. shallowEqual

3. Memoized selector

useSelector kontra connect?

Hooki porównują wyniki selektorów pod kątem referencji; connect() porównuje powierzchownie właściwości z mapStateToProps. Hooki są standardem, natomiast connect() nadal jest wspierany.

Dlaczego potrzebne są memoizowane selektory?

Ponownie obliczają dane pochodne tylko wtedy, gdy zmieniają się dane wejściowe, w przeciwnym razie zwracają ten sam referent. Klasycznym przykładem jest filtr:

todos
  ↓
filter completed
  ↓
new array

Co to są RootState, AppDispatch i withTypes()?

Wynikowy typ całego stanu:

type RootState =
  ReturnType<typeof store.getState>

Wynikowy typ dyspatchu, włączając w to middleware takie jak thunks:

type AppDispatch =
  typeof store.dispatch

A także narzędzia pomocnicze, które zwracają hooki przypisane do tych typów:

useDispatch.withTypes<AppDispatch>()

useSelector.withTypes<RootState>()

useStore.withTypes<AppStore>()

Dlaczego hooki typowane?

Zastępują one powtarzające się adnotacje takie jak

useSelector(
  (state: RootState) =>
    state.user
)

zamiast tego

useAppSelector(
  state => state.user
)

Dlaczego selektory muszą być czyste?

Selektor może zostać wywołany kilka razy dla tego samego stanu – podczas renderowania, po każdej akcji oraz w momentach, których nie kontroluje kod. Każdy efekt uboczny mógłby zostać wywołany nieprzewidywalną liczbę razy.

Do czego służy useStore?

Nieczęste przypadki, w których rzeczywiście potrzebny jest obiekt store. Odczytywanie stanu w celach renderowania odbywa się za pośrednictwem useSelector().

Czym są zombie children i stale props?

Oba to rzadkie problemy związane z kolejnością wykonywania operacji. Zombie child obsługuje aktualizację przed tym, jak jego rodzic go usunie, i odczytuje usunięte dane; stale props oznaczają sytuację, gdy selektor zależny od propów jest wykonywany przy aktualnym stanie, ale ze starymi wartościami propów. Selektory obronne radzą sobie z oboma problemami.

Czy batch() jest nadal potrzebne w React 18?

Zazwyczaj nie, ponieważ React 18 automatycznie grupuje operacje, ale nadal można na nie natrafić w starszym kodzie.

Dlaczego connect i hooks mogą renderować w inny sposób?

Różne modele subskrypcji oraz różne sposoby porównywania:

connect()
    ↓
shallow equality

versus

useSelector()
    ↓
strict === equality

Dlaczego useSelector jest wykonywany tak często?

On:

  1. jest wykonywany podczas renderowania
  2. nasłuchuje zmian w store’ie
  • wykonywany ponownie po każdej wysłanej akcji
  • ponownie wykorzystuje swoje zapisane wyniki podczas renderowania tylko wtedy, gdy funkcja selektora i stan pozostają niezmienione
  • Zatem selektor inline, będący nową funkcją przy każdym renderowaniu, jest wykonywany przy każdym renderowaniu; stabilna referencja selektora pozwala React-Redux ominąć to wywołanie.

    Co studiować, według kolejności priorytetów

    Poziom pierwszy: konieczne do opanowania

    Provider
    useSelector
    useDispatch
    Redux Store flow
    Selectors
    === equality
    Re-render behavior
    Redux Toolkit + React-Redux
    TypeScript
    RootState
    AppDispatch
    .withTypes()
    

    Poziom drugi: solidna wiedza praktyczna

    shallowEqual
    Memoized selectors
    createSelector
    useStore
    connect
    mapStateToProps
    mapDispatchToProps
    ConnectedProps
    React.memo
    

    Poziom trzeci: zaawansowane tematy

    Stale props
    Zombie children
    Selector + props
    Selector memoization
    Custom context
    Development mode checks
    SSR serverState
    batch()
    

    Czego nie należy zapamiętywać

    Nie musisz uczyć się dokumentacji linijka po linijce. Możesz pominąć:

    • sposób wewnętrznej implementacji mechanizmu subskrypcji
    • rzadko używane opcje connect()
    • dawno przestarzałe wzorce
    • detale implementacji kodu źródłowego
  • dokładny tekst każdego ostrzeżenia o rozwoju
  • każda zaawansowana właściwość Provider
  • Ważne jest uzasadnienie tych zasad. Znajomość faktu

    useSelector uses ===
    

    jest mniej przydatna niż umiejętność udzielenia odpowiedzi

    Why?
    

    mianowicie:

    Because returning a new object
    creates a new reference.
    

    co prowadzi do łańcucha

    New reference
         ↓
    === false
         ↓
    re-render
    

    Jeśli zrozumiesz ten łańcuch, możesz samodzielnie wyprowadzić większość pozostałych zasad.

    Dziesięć zasad do przestrzegania

    1. Czytaj za pomocą useSelector

    useSelector()
    

    tak komponenty odczytują stan.

    2. Zapisuj za pomocą useDispatch

    useDispatch()
    

    tak komponenty wysyłają działania.

    3> Otocz aplikację Provider

    <Provider store={store}>
    

    4. Pamiętaj o zasadach porównywania

    useSelector → ===
    connect → shallow comparison
    

    5. Nigdy nie wybieraj stanu korzeniowego

    useSelector(state => state)
    

    6. Bądź ostrożny z selektorami zwracającymi obiekty

    useSelector(state => ({
      ...
    }))
    

    7. Memorizuj kosztowne dane pochodne

    Memoized selector
    

    8. Zachowuj dyscyplinę w wyborze selektorów

    Pure
    Predictable
    Granular
    

    9. Najpierw zadeklaruj typy sklepu, potem hooki

    Najpierw typy wywnioskowane:

    RootState
    AppDispatch
    AppStore
    

    potem hooki aplikacji zbudowane na ich podstawie:

    useAppSelector
    useAppDispatch
    useAppStore
    

    10. Używaj nowoczesnego połączenia

    Redux Toolkit
           +
    React-Redux Hooks
    

    Cała API na jednej stronie

    Kompaktowy przewodnik obejmujący podstawowe hooki, nawyki dotyczące wydajności, typy TypeScript, starszą API oraz zaawansowane tematy:

    ┌───────────────────────────────────────────────┐
    │               REACT-REDUX                     │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ <Provider store={store}>                      │
    │        ↓                                      │
    │ Makes Redux available to React                │
    │                                               │
    │ useSelector()                                 │
    │        ↓                                      │
    │ READ state                                    │
    │        ↓                                      │
    │ Default comparison: ===                       │
    │                                               │
    │ useDispatch()                                 │
    │        ↓                                      │
    │ DISPATCH actions                              │
    │                                               │
    │ useStore()                                    │
    │        ↓                                      │
    │ Direct store access                           │
    │        ↓                                      │
    │ Use rarely                                    │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ PERFORMANCE                                   │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ Avoid state => state                          │
    │ Avoid unnecessary object creation             │
    │ Use granular selectors                        │
    │ Use shallowEqual when appropriate             │
    │ Use memoized selectors for derived data       │
    │ Use React.memo when justified                 │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ TYPESCRIPT                                    │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ RootState = ReturnType<typeof store.getState> │
    │ AppDispatch = typeof store.dispatch           │
    │ AppStore = typeof store                       │
    │                                               │
    │ useAppSelector                                │
    │ useAppDispatch                                │
    │ useAppStore                                   │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ LEGACY / EXISTING APPLICATIONS                │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ connect()                                     │
    │ mapStateToProps                               │
    │ mapDispatchToProps                            │
    │ ConnectedProps                                │
    │                                               │
    ├───────────────────────────────────────────────┤
    │ ADVANCED                                      │
    ├───────────────────────────────────────────────┤
    │                                               │
    │ Stale Props                                   │
    │ Zombie Children                               │
    │ Custom Context                                │
    │ SSR serverState                               │
    │ Development checks                            │
    │ batch()                                       │
    │                                               │
    └───────────────────────────────────────────────┘
    

    A cykl, narysowany ponownie z ścieżkami odczytu i zapisu obok siebie:

                            REACT
                               │
                               │
                        <Provider />
                               │
                               ▼
                        ┌─────────────┐
                        │ Redux Store │
                        └──────┬──────┘
                               │
                   ┌───────────┴───────────┐
                   │                       │
                   ▼                       ▲
            useSelector()             useDispatch()
                   │                       │
                   │                       │
                 READ                    ACTION
                   │                       │
                   │                       │
                   │                  ┌────┴─────┐
                   │                  │ Reducer  │
                   │                  └────┬─────┘
                   │                       │
                   │                       ▼
                   │                  New State
                   │                       │
                   └───────────────────────┘
                               │
                               ▼
                        Equality Check
                               │
                        ┌──────┴──────┐
                        │             │
                      Same         Changed
                        │             │
                        ▼             ▼
                    No Redux      Re-render
                     render
    

    Dla nowego projektu TypeScript zalecana konfiguracja ma następujący wygląd:

                Redux Toolkit
                       +
                  React-Redux
                       │
            ┌──────────┴──────────┐
            │                     │
         Store                 Provider
            │                     │
            │               Application tree
            │                     │
            └──────────┬──────────┘
                       │
              ┌────────┴────────┐
              │                 │
       useAppSelector()   useAppDispatch()
              │                 │
            READ              WRITE
              │                 │
              └────────┬────────┘
                       │
                    Redux
                       │
                  New State
                       │
                    Selector
                       │
                    Re-render
    

    Podsumowanie

    React-Redux staje się znacznie prostszy, gdy przestaniesz traktować jego elementy eksportowane jako niepowiązane narzędzia. Ten wykaz nazw

    Provider
    useSelector
    useDispatch
    connect
    shallowEqual
    createSelector
    useStore
    

    Opisuje pojedynczą łańcuchową strukturę z jednym zadaniem na każdym etapie. Dostawca udostępnia magazyn danych:

    Provider
       ↓
    makes Store available
    

    Hook selektora odczytuje:

    useSelector
       ↓
    reads selected state
    

    Hook dispatch wysyła:

    useDispatch
       ↓
    sends actions
    

    Reduktorzy wytwarzają następny stan:

    Reducer
       ↓
    creates new state
    

    Selektory kształtują ten stan pod potrzeby interfejsu użytkownika:

    Selector
       ↓
    derives data
    

    Sprawdzenie równości decyduje, czy coś istotnego się zmieniło:

    Equality
       ↓
    decides whether selected data changed
    

    A React zajmuje się renderowaniem:

    React
       ↓
    re-renders when necessary
    

    Z drugiej strony, w TypeScript łańcuch jest równie liniowy:

    Store
     ↓
    RootState
    AppDispatch
    AppStore
     ↓
    .withTypes()
     ↓
    useAppSelector()
    useAppDispatch()
    useAppStore()
    

    Gdy komponent renderuje więcej, niż powinien, za każdym razem należy rozważyć te same cztery kwestie:

    useSelector()
          ↓
    What does my selector return?
          ↓
    Is the reference stable?
          ↓
    Does the selected value actually change?
          ↓
    Should this component re-render?
    

    Główne wnioski:

    • Większość problemów z wydajnością w React-Redux wynika z selektorów, które zwracają nowe referencje, a nie z samego Redux.
  • useSelector() porównuje za pomocą ===; connect() wykonuje porównanie powierzchowne. Przenoszenie kodu między nimi bez dostosowania selektorów zmienia zachowanie.
  • Wybieraj selektory w sposób precyzyjny, przechowuj dane pochodne w pamięci za pomocą instancji createSelector na poziomie modułu i używaj shallowEqual tylko wtedy, gdy wynikiem ma być celowo obiekt.
  • Wynoś RootState, AppDispatch oraz AppStore z magazynu danych i twórz typowane hooki za pomocą .withTypes().
  • Propy przestarzałe, „zombie children”, niestandardowe konteksty, batch() oraz serverState są godne zrozumienia, ale stanowią przypadki krawędziowe, a nie codzienne problemy.
  • Jako test samodzielny spróbuj wyjaśnić z pamięci każdy nagłówek tego przewodnika – od Provider i równości, przez typowane hooki aż po serverState – oraz napisz podstawową konfigurację bez szukania informacji gdziekolwiek indziej.

    Jeśli chodzi o szczegóły, oficjalnymi źródłami są API hooków, API Providera, przewodnik po używaniu z TypeScript, Szybki start oraz dokumentacja do connect(). Najpierw przestudiuj podstawowy cykl i koncepcję równości, potem selektory, TypeScript, wydajność, connect oraz przypadki krawędziowe, aby szczegóły API miały konkretny punkt odniesienia.

    Literatura pokrewna

  • Drilling prop to nie jest powód do instalacji Redux lub Zustand — Przetestuj cztery powszechne powody dodawania biblioteki do zarządzania stanem w funkcjonującym kodzie React: Context dla drilling prop, useState, useSyncExternalStore oraz koszt ponownego renderowania Context.
  • Scheduler React i pętla wydarzeń: Kto naprawdę decyduje o czasie wykonywania zadań — Zobacz, jak współpracujący scheduler React działa wewnątrz pętli wydarzeń JavaScript, dlaczego transycje mogą zostać wstrzymane oraz dlaczego żaden scheduler nie może uratować zablokowanej wątku.
  • Rozumienie React Hooks poprzez zdjęcia renderu i kompromisy — Zaawansowany model myślowy dotyczący Hooków w React i React Native: zdjęcia renderu, efekty, refy, memoizacja, niestandardowe Hooki oraz sposoby wyjaśniania kompromisów podczas rozmów kwalifikacyjnych.