Dzisiejszy wpis opowie nam trochę o useReducer()
, który może być używany zamiast useState()
, aby zarządzać stanem komponentu. Z reguły w komponentach funkcyjnych używamy useState()
, z racji tego, że useState()
jest łatwym w użyciu hookiem. Zaczynając zabawę z Reactem, komponentami funkcyjnymi i hookami, zapewne w pierwszej kolejności używamy też useState()
.
Najpierw zacznijmy od prostego przykładu, aby pokazać jak wygląda kod, a później spróbuję przedstawić pewne plusy użycia useReducer()
.
W przykładzie będziemy tworzyć komponent z dwoma przyciskami, jeden przycisk będzie przypisywał losową wartość, wygenerowaną przez Math.random()
, drugi będzie czyścił wartość dla stanu text
.
Prosty przykład z useState()
:
function App() { const [text, setText] = React.useState(Math.random()); return ( <div> Text: {text} <br /> <button onClick={() => setText(Math.random())}>Generate random text</button> <button onClick={() => setText('')}>Remove text</button> </div> ); }
Podobny przykład z useReducer()
:
function textReducer(state, action) { switch (action.type) { case 'generate': return Math.random(); case 'remove': return ''; } } function App() { const [text, dispatch] = React.useReducer(textReducer, Math.random()); return ( <div> Text: {text} <br /> <button onClick={() => dispatch({type: 'generate'})}>Generate random text</button> <button onClick={() => dispatch({type: 'remove'})}>Remove text</button> </div> ); }
Jak widać, w przypadku useReducer()
tego kodu jest trochę więcej, ale istnieją przypadki, dla których warto rozważyć użycie useReducer()
zamiast useState()
.
Budowa useReducer()
Wróćmy jeszcze do useReducer()
i przyjrzyjmy się, w jaki sposób zbudowany jest sam hook.
const [text, dispatch] = React.useReducer(reducerFunction, initialValue);
useReducer()
przyjmuje jako pierwszy argument funkcję reducera, jako drugi parametr otrzymuje wartość początkową. Zwraca natomiast stan oraz dispatch. Jest to koncept bardzo podobny do Redux. (https://redux.js.org/).
Po kliknięciu w przycisk, poprzez dispatch()
z odpowiednim type
(nazwa dowolna, natomiast warto trzymać się pewnej konwencji), uruchamiany jest fragment logiki wewnątrz textReducer()
. Finalnie zwracany jest nowy stan.
Co zyskujemy dzięki użyciu useReducer()
?
Reużywalność logiki reducera
Przede wszystkim dzięki wydzieleniu części odpowiadającej za modyfikację tekstu do zewnętrznej funkcji textReducer(state, action)
możemy używać jej w innych komponentach. Wynosimy funkcję gdzieś na zewnątrz i importujemy w komponentach, które mają używać podobnej logiki.
function KomponentPierwszy() { const [text, dispatch] = React.useReducer(textReducer, Math.random()); .... } function KomponentPierwszy() { const [someText, dispatch] = React.useReducer(textReducer, Math.random()); .... }
Zmiany tylko przez zdefiniowaną logikę i funkcję reducera
Kolejny plus (tutaj znów możemy odnieść się do Redux). Aby zmienić stan dla text
, musimy użyć funkcji reducera. Co to oznacza?
Zmiana zachodzi tylko poprzez funkcję. Nie powinno być zatem sytuacji, że zmienimy zawartość text
w dowolnym dla nas miejscu. W ten sposób łatwiej debugować aplikację i zarządzać stanem komponentu w świadomy sposób. W funkcji bowiem, zdefiniowaną mamy logikę, która jest właściwa dla logiki komponentu.
Jeżeli nadal to co napisałem wyżej jest niejasne, posłużę się szybkim przykładem:
Używając useState(text, setText)
, możemy przekazać do stanu text
dowolną wartość. Załóżmy, że chcemy generować tylko liczbę, poprzez Math.random()
i nie dopuszczamy innej wartości. W przypadku użycia useState()
musimy sami zadbać o to, aby gdzieś w komponencie nie zrobić np. setText('STRING')
, przypisując błędną wartość. Korzystając z useReducer()
i naszej funkcji textReducer()
sprawa jest załatwiona z automatu, bowiem wywołujemy tylko zdefiniowane fragmenty logiki poprzez dispatch({type: ...})
Nazwanie złożonej logiki
Jedno z podejść w programowaniu mówi nam, aby zmienne, metody itd. były nazywane w taki sposób aby dało się z tego wyczytać również logikę aplikacji… Dzięki zastosowaniu funkcji reducera i odpowiednim nazwaniu action.type
możemy to trochę usprawnić.
Stan jako obiekt
Rozszerzmy lekko nasz przykład i dodajmy do niego logikę, która informuje nas, czy kiedykolwiek kliknęliśmy w przycisk. Poza stanem text
pojawia nam się drugi stan… Bez użycia useReducer()
musielibyśmy używać dwa razy useState()
, raz dla stanu text, drugi raz dla stanu buttonClicked.
W tym przypadku zmieniamy initial state na obiekt, oraz lekko modyfikujemy reducer.
function textReducer(state, action) { switch (action.type) { case 'generate': return { text: Math.random(), buttonClicked: true } case 'remove': return { text: '', buttonClicked: true }; } } function App() { const [textState, dispatch] = React.useReducer(textReducer, {text: Math.random(), buttonClicked: false}); return ( <div> Text: {textState.text} <br /> Button clicked: {textState.buttonClicked.toString()} <button onClick={() => dispatch({type: 'generate'})}>Generate random text</button> <button onClick={() => dispatch({type: 'remove'})}>Remove text</button> </div> ); }
Oczywiście jest to bardzo prosty przykład, ale kiedy logika będzie bardziej skomplikowana, użycie useReducer()
może mieć dobre zastosowanie.
Łatwe testowanie
Przyjrzyjmy się poniższej funkcji:
function calculatorReducer(state, action) { switch (action.type) { case 'PLUS': return state + 1; case 'MINUS': return state - 1; } }
Dla tych samych wartości wejściowych, funkcja zawsze zwróci to samo. Mamy tutaj do czynienia z czystą funkcją. W związku z tym, bardzo łatwo jest testować taką funkcję. Przekazujemy początkowy stan, wywołujemy różne akcje i sprawdzamy, czy wartość po wywołaniu jest wartością oczekiwaną, bardzo proste i przyjemne.
Przy okazji zapraszam do innego artykułu z mojego bloga, dotyczącego czystych funkcji.
Zależność od innej wartości stanu
Wyobraźmy sobie, że logika w naszym komponencie zależy od kilku stanów, które w jakiś sposób na siebie oddziałują. Na szybko przerobiłem funkcję reducera, aby pokazać z czym mamy do czynienia. Załóżmy, że możemy klikać w nasz przycisk, ale nie więcej niż 10 razy. Mamy zatem dwa stany, jeden przechowujący aktualny tekst oraz drugi, który liczy ilość kliknięć w przycisk.
function textReducer(state, action) { switch (action.type) { case 'generate': if (state.clickCount == 10) { return state; } return { clickCount: state.clickCount + 1, text: Math.random() } case 'remove': return { ...state, text: '' }; } }
W przypadku użycia useState()
mielibyśmy zdefiniowane zapewne dwa stany oraz handler, w którym byłaby obsługiwana logika.
Wady
Na pewno pierwszą wadą jest ilość kodu, który musimy napisać. Czasami mamy bardzo proste komponenty, gdzie użycie useState()
będzie intuicyjne i po prostu… lepsze. Może to być chociażby jakaś flaga na zasadzie loading
lub jakiś inny pojedynczy stan. W takim przypadku nie ma sensu na siłę używać useReducer()
.
Osobiście skłaniałbym się do używania useReducer()
w bardziej skomplikowanej logice. Również można rozważyć użycie useReducer()
, gdy wartość stanu w komponencie zależy od innego stanu tego samego komponentu, co pokazałem w przykładzie z licznikiem kliknięć i blokadą zmiany stanu.
Pamiętajmy jednak, że skomplikowana logika może świadczyć o tym, że nasz komponent jest tą logiką „przeładowany” i może warto pomyśleć nad innym podziałem kodu.
Dzięki za Twój czas. Mam nadzieję, że artykuł zainspirował Ciebie do czegoś i coś z niego wyniosłeś/aś, bowiem useReducer(
) jest rzadziej używanym hookiem niż useState()
. 🙂
Źródła:
Obraz: https://unsplash.com/photos/wkieEIVb1pA
Hooki na stronie React: https://pl.reactjs.org/docs/hooks-intro.html
Redux: https://redux.js.org/basics/usage-with-react