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 u useReducer
  • 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:

  1. Prelazak sa postavljanja state-a na otpremanje akcija.
  2. Pisanje reducer funkcije.
  3. 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.)

Napomena

Action objekat može imati bilo kakav oblik.

Po konvenciji, uobičajeno je da mu date string type koji opisuje šta se desilo i prosledite ostale informacije u drugim poljima. type je jedinstven za komponentu, pa bi u ovom primeru i 'added' i 'added_task' bili u redu. Odaberite ime koje govori šta se desilo!

dispatch({
// jedinstveno za komponentu
type: 'what_happened',
// ostala polja idu ovde
});

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:

  1. Deklarisati trenutni state (tasks) kao prvi argument.
  2. Deklarisati action objekat kao drugi argument.
  3. 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.

Napomena

Kod iznad koristi if/else iskaze, ali je konvencija da se koriste switch iskazi unutar reducer-a. Rezultat je isti, ali na prvi pogled switch iskazi mogu biti čitljiviji.

Mi ćemo ih koristiti svuda kroz ostatak dokumentacije:

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);
}
}
}

Preporučujemo da obmotate case blok u { i } vitičaste zagrade kako se promenljive deklarisane u različitim case-ovima ne bi preplitale. Takođe, case se uglavnom završava sa return. Ako zaboravite return, kod će “propasti” u naredni case, što može dovesti do grešaka!

Ako još uvek niste navikli na switch iskaze, upotreba if/else je potpuno u redu.

Deep Dive

Zašto se reducer-i tako zovu?

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:

  1. Reducer funkciju
  2. Inicijalni state

I vraća:

  1. Stateful vrednost
  2. 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. Sa useReducer-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. Sa useReducer-om možete dodati console log u vaš reducer da biste videli svako ažuriranje state-a, kao i zašto se desilo (zbog kog action-a). Ako je svaki action ispravan, znaćete da je greška u samoj logici reducer-a. Međutim, morate proći kroz više koda u poređenju sa useState.
  • 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 i useReducer: 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čitih set_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 u useReducer:
    1. Otpremite akcije iz event handler-a.
    2. Napišite reducer funkciju koja vraća naredni state za zadati state i akciju.
    3. Zamenite useState sa useReducer.
  • 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'},
];