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.
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:
- czyta aktualny stan sklepu
- wywołuje twój selektor z tym danymi
- przechowuje zwróconą wartość jako „ostatnio wybrany” wynik
- rejestruje subskrypcję do sklepu dla tego komponentu
- ponownie uruchamia selektor po każdej wysłanej akcji
- porównuje poprzedni wynik z nowym
- 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:
- zwracają tylko dane wykorzystywane przez komponent
- przekazują stabilne referencje, gdy nic istotnego się nie zmieniło
- nie tworzą nowych obiektów ani tablic bez konieczności
- 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:
- Dziecko jest podpisane na zbiór danych.
- Jakaś akcja usuwa dane, które dziecko wyświetla.
- Podczas następnego renderowania rodzic przestanie wyświetlać to dziecko.
- Zanim to nastąpi, wykonywany jest proces podpisania dziecka.
- 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:
- jest wykonywany podczas renderowania
- nasłuchuje zmian w store’ie
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
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.createSelector na poziomie modułu i używaj shallowEqual tylko wtedy, gdy wynikiem ma być celowo obiekt.RootState, AppDispatch oraz AppStore z magazynu danych i twórz typowane hooki za pomocą .withTypes().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
- React Query i Redux: Przemyślenie stanu serwera w dużych aplikacjach — Dowiedz się, dlaczego aplikacja do czatowania w środowisku produkcyjnym używała TanStack Query zamiast Redux do zarządzania danymi serwera, oraz gdzie Redux nadal ma zastosowanie w nowoczesnej architekturze React.
- Strukturyzacja warstwy danych TanStack Query, od queryOptions do Rollbacks — Buduj warstwę danych TanStack Query krok po kroku: wspólne queryOptions, generatorzy kluczy, selektory, paginacja, wcześniejsze pobieranie danych, centralne unieważnianie wyników oraz bezpieczne aktualizacje optymistyczne.