Izdvajanje state logike u reducer
Komponente sa mnogo ažuriranja state-a koji se prostiru kroz mnogo event handler-a mogu postati preobimne. U tim slučajevima, možete grupisati svu logiku ažuriranja state-a izvan komponente u jednu funkciju koja se naziva reducer.
Naučićete:
- Šta je to reducer funkcija
- Kako da refaktorišete
useState
uuseReducer
- Kada koristiti reducer
- Kako da pravilno napišete jedan
Grupisati state logiku sa reducer-om
Dok se vaše komponente komplikuju, na prvi pogled može biti teško uočiti sve različite načine ažuriranja state-a neke komponente. Na primer, TaskApp
komponenta ispod sadrži niz tasks
u state-u i koristi tri različita event handler-a za dodavanje, brisanje i izmenu zadataka:
import { useState } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, setTasks] = useState(initialTasks); function handleAddTask(text) { setTasks([ ...tasks, { id: nextId++, text: text, done: false, }, ]); } function handleChangeTask(task) { setTasks( tasks.map((t) => { if (t.id === task.id) { return task; } else { return t; } }) ); } function handleDeleteTask(taskId) { setTasks(tasks.filter((t) => t.id !== taskId)); } return ( <> <h1>Plan puta u Pragu</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Poseti Kafkin muzej', done: true}, {id: 1, text: 'Gledaj lutkarsku predstavu', done: false}, {id: 2, text: 'Slikaj Lenonov zid', done: false}, ];
Svaki od ovih event handler-a poziva setTasks
kako bi ažurirao state. Kako komponenta raste, raste i količina state logike u njoj. Kako biste smanjili kompleksnost i čuvali svu logiku na jednom, lako dostupnom mestu, možete pomeriti tu state logiku u posebnu funkciju izvan vaše komponente, funkciju pod imenom “reducer”.
Reducer-i predstavljaju drugi način za upravljanje state-om. Možete se migrirati od useState
do useReducer
u tri koraka:
- Prelazak sa postavljanja state-a na otpremanje akcija.
- Pisanje reducer funkcije.
- Korišćenje reducer-a iz vaše komponente.
Korak 1: Prelazak sa postavljanja state-a na otpremanje akcija
Vaši event handler-i trenutno specificiraju šta raditi postavljanjem state-a:
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
Uklonite svu logiku postavljanja state-a. Ono što vam ostaje su tri event handler-a:
handleAddTask(text)
se poziva kada korisnik klikne “Dodaj”.handleChangeTask(task)
se poziva kada korisnik štiklira zadatak ili klikne “Sačuvaj”.handleDeleteTask(taskId)
se poziva kada korisnik klikne “Obriši”.
Upravljanje state-om pomoću reducer-a se malo razlikuje od direktnog postavljanja state-a. Umesto da postavljanjem state-a React-u kažete “šta da radi”, možete specificirati “šta je korisnik upravo uradio” otpremanjem “akcija” iz event handler-a. (Logika ažuriranja state-a će živeti negde drugde!) Znači, umesto “postavljanja tasks
niza” kroz event handler, otpremate akciju “added/changed/deleted”. Ovo bolje opisuje nameru korisnika.
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
Objekat koji prosleđujete u dispatch
se naziva “akcija”:
function handleDeleteTask(taskId) {
dispatch(
// "action" objekat:
{
type: 'deleted',
id: taskId,
}
);
}
To je običan JavaScript objekat. Možete odlučiti šta da smestite u njega, ali generalno bi trebao da sadrži minimum informacija o onome što se desilo. (Dodaćete dispatch
funkciju u narednom koraku.)
Korak 2: Pisanje reducer funkcije
Reducer funkcija je mesto gde ćete staviti vašu state logiku. Prima dva argumenta, trenutni state i action objekat, a vraća naredni state:
function yourReducer(state, action) {
// vraća naredni state koji će React postaviti
}
React će postaviti state na ono što vratite iz reducer-a.
Da biste pomerili logiku postavljanja state-a iz event handler-a u reducer funkciju u ovom primeru, uradićete sledeće:
- Deklarisati trenutni state (
tasks
) kao prvi argument. - Deklarisati
action
objekat kao drugi argument. - Vratiti naredni state iz reducer-a (na šta će React postaviti state).
Ovde je sva logika postavljanja state-a migrirana u reducer funkciju:
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Nepoznata akcija: ' + action.type);
}
}
Pošto reducer funkcija prima state (tasks
) kao argument, možete ga deklarisati izvan vaše komponente. Ovo smanjuje nivo uvlačenja i čini kod čitljivijim.
Deep Dive
Iako reducer-i mogu da “smanje” količinu koda unutar komponente, zapravo su nazvani po reduce()
operaciji koju možete izvršiti nad nizovima.
reduce()
operacija vam omogućava da uzmete niz i “akumulirate” jednu vrednost:
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
Funkcija koju prosleđujete u reduce
se naziva “reducer”. Uzima rezultat do sada i trenutni element, a vraća naredni rezultat. React reducer-i su primer iste ideje: uzimaju state koji imate do sad i akciju, a vraćaju naredni state. Na ovaj način, oni akumuliraju akcije tokom vremena u state.
Možete koristiti reduce()
metodu nad initialState
i nizom actions
da izračunate konačni state prosleđivanjem vaše reducer funkcije:
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Poseti Kafkin muzej'}, {type: 'added', id: 2, text: 'Gledaj lutkarsku predstavu'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Slikaj Lenonov zid'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
Verovatno ovo nećete morati da radite samostalno, ali to je slično onome što React radi!
Korak 3: Korišćenje reducer-a iz vaše komponente
Konačno, trebate zakačiti tasksReducer
u vašu komponentu. Import-ujte useReducer
Hook iz React-a:
import { useReducer } from 'react';
Onda možete zameniti useState
:
const [tasks, setTasks] = useState(initialTasks);
sa useReducer
:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer
Hook liči na useState
—morate proslediti inicijalni state, a on vraća stateful vrednost i način da postavite state (u ovom slučaju, dispatch funkciju). Ali, malo se razlikuje.
useReducer
Hook prima dva argumenta:
- Reducer funkciju
- Inicijalni state
I vraća:
- Stateful vrednost
- Dispatch funkciju (za “otpremanje” korisničkih akcija u reducer)
Sad je potpuno povezan! Ovde je reducer deklarisan na dnu fajla komponente:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Plan puta u Pragu</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('Nepoznata akcija: ' + action.type); } } } let nextId = 3; const initialTasks = [ {id: 0, text: 'Poseti Kafkin muzej', done: true}, {id: 1, text: 'Gledaj lutkarsku predstavu', done: false}, {id: 2, text: 'Slikaj Lenonov zid', done: false}, ];
Ako želite, možete čak reducer pomeriti i u drugi fajl:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import tasksReducer from './tasksReducer.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Plan puta u Pragu</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Poseti Kafkin muzej', done: true}, {id: 1, text: 'Gledaj lutkarsku predstavu', done: false}, {id: 2, text: 'Slikaj Lenonov zid', done: false}, ];
Logika komponente može biti čitljivija kada ovako razdvojite zaduženja. Sada event handler-i samo specificiraju šta se desilo otpremanjem akcija, a reducer funkcija određuje kako se state ažurira kao odgovor na njih.
Poređenje useState
i useReducer
Reducer-i imaju i mane! Evo par načina da ih poredite:
- Količina koda: Uopšteno, sa
useState
-om trebate pisati manje koda unapred. SauseReducer
-om morate da pišete i reducer funkciju i akcije za otpremanje. Međutim,useReducer
može pomoći da smanjite kod ako mnogo event handler-a menja state na sličan način. - Čitljivost: Lako je čitati
useState
kada je ažuriranje state-a jednostavno. Kada state postane komplikovaniji, može preopteretiti kod vaš komponente i učiniti ga težim za razumevanje. U ovom slučaju,useReducer
vam omogućava da jasno odvojite kako u logici ažuriranja od šta se desilo iz event handler-a. - Debug-ovanje: Kada imate bug sa
useState
-om, može biti teško da otkrijete gde je state pogrešno postavljen i zašto. SauseReducer
-om možete dodati console log u vaš reducer da biste videli svako ažuriranje state-a, kao i zašto se desilo (zbog kogaction
-a). Ako je svakiaction
ispravan, znaćete da je greška u samoj logici reducer-a. Međutim, morate proći kroz više koda u poređenju sauseState
. - Testiranje: Reducer je čista funkcija koja ne zavisi od vaše komponente. To znači da je možete export-ovati i testirati izolovano. Iako je obično najbolje testirati komponente u realističnijem okruženju, za kompleksniju logiku ažuriranja state-a može biti korisno da se uverite da vaš reducer vraća određeni state za određeni inicijalni state i akciju.
- Lična preferenca: Neki ljudi vole reducer-e, ostali ne vole. To je u redu. Stvar je preferenci. Uvek možete menjati između
useState
iuseReducer
: jednaki su!
Preporučujemo da koristite reducer ako često nailazite na bug-ove zbog neispravnog ažuriranja state-a u nekoj komponenti i želite da uvedete veću strukturu u njen kod. Ne morate koristiti reducer-e za sve: slobodno kombinujte! Možete čak koristiti useState
i useReducer
u istoj komponenti.
Pravilno pisanje reducer-a
Zapamtite ova dva saveta kada pišete reducer-e:
- Reducer-i moraju biti čisti. Slično kao i za state updater funkcije, reducer-i se izvršavaju tokom rendera! (Akcije su u redu čekanja pre narednog rendera.) Ovo znači da reducer-i moraju biti čisti—isti input-i uvek moraju da vrate isti rezultat. Ne bi trebali da šalju zahteve, zakazuju timeout-e ili da izvršavaju neke propratne efekte (operacije koje utiču na stvari van komponente). Trebaju da ažuriraju objekte i nizove bez mutacija.
- Svaka akcija opisuje jednu korisničku interakciju, čak iako to dovodi do višestrukih promena u podacima. Na primer, ako korisnik klikne “Resetuj” u formi sa pet polja kojom upravlja reducer, ima više smisla da se otpremi jedna
reset_form
akcija umesto pet različitihset_field
akcija. Ako logujete svaku akciju u reducer-u, taj log bi trebao biti dovoljno jasan da biste rekonstruisali redosled interakcija ili odgovora koji su se desili. Ovo pomaže prilikom debug-ovanja!
Pisanje konciznih reducer-a sa Immer-om
Kao i kod ažuriranja objekata i nizova u običnom state-u, možete koristiti Immer biblioteku da učinite reducer-e konciznijim. Ovde, useImmerReducer
vam omogućava da mutirate state sa push
ili arr[i] =
dodelom:
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
Reducer-i moraju biti čisti, tako da ne bi trebali da mutiraju state. Ali, Immer vam pruža poseban draft
objekat koji možete sigurno mutirati. Ispod haube, Immer će napraviti kopiju vašeg state-a sa promenama koje ste napravili nad draft
-om. Zato reducer-i koje koristite uz pomoć useImmerReducer
-a mogu da mutiraju svoj prvi argument i ne moraju da vrate state.
Recap
- Za konvertovanje iz
useState
uuseReducer
:- Otpremite akcije iz event handler-a.
- Napišite reducer funkciju koja vraća naredni state za zadati state i akciju.
- Zamenite
useState
sauseReducer
.
- Reducer-i zahtevaju da napišete više koda, ali pomažu u debug-ovanju i testiranju.
- Reducer-i moraju biti čisti.
- Svaka akcija opisuje jednu korisničku interakciju.
- Koristite Immer ako želite da pišete reducer-e u stilu mutacija.
Izazov 1 od 4: Otpremiti akcije iz event handler-a
Trenutno, event handler-i u ContactList.js
i Chat.js
imaju // TODO
komentare. Zbog toga pisanje u input ne radi, a klik na dugmiće ne menja izabranog primaoca.
Zamenite ova dva // TODO
-a sa kodom koji poziva dispatch
za odgovarajuće akcije. Da biste videli očekivani oblik i tipove akcija, proverite reducer u messengerReducer.js
. Reducer je već napisan, pa ga ne morate menjati. Samo je potrebno da otpremite akcije iz ContactList.js
i Chat.js
.
import { useReducer } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; import { initialState, messengerReducer } from './messengerReducer'; export default function Messenger() { const [state, dispatch] = useReducer(messengerReducer, initialState); const message = state.message; const contact = contacts.find((c) => c.id === state.selectedId); return ( <div> <ContactList contacts={contacts} selectedId={state.selectedId} dispatch={dispatch} /> <Chat key={contact.id} message={message} contact={contact} dispatch={dispatch} /> </div> ); } const contacts = [ {id: 0, name: 'Taylor', email: 'taylor@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];