Tilalogiikan siirtäminen reduktoriin

Komponentit joissa on paljon tilapäivityksiä ja jotka jakavat tilan päivitykset useiden tapahtumankäsittelijöiden välillä, voivat olla hämmentäviä. Näissä tapauksissa voit yhdistää kaiken tilanpäivityslogiikan komponentin ulkopuolelle yhteen funktioon, jota kutsutaan reduktoriksi (engl. reducer).

Tulet oppimaan

  • Mikä on reduktorifunktio
  • Miten refaktoroidaan useState useReducer-käyttöön
  • Milloin reduktoria kannattaa käyttää
  • Miten se kirjoitetaan hyvin

Tilalogiikan yhdistäminen reduktoriin

Kun komponenttisi kasvavat monimutkaisemmiksi, voi olla vaikeampaa nähdä yhdellä silmäyksellä kaikkia eri tapoja, joilla komponentin tilaa päivitetään. Esimerkiksi alla oleva TaskApp-komponentti sisältää taulukon tasks ja käyttää kolmea eri tapahtumankäsittelijää tehtävien lisäämiseen, poistamiseen ja muokkaamiseen:

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>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

Kukin tapahtumankäsittelijä kutsuu setTasks-funktiota tilan päivittämiseksi. Kun komponentti kasvaa, kasvaa myös tilalogiikan määrä. Tilalogiikan vähentämiseksi ja sen helpottamiseksi voit siirtää tilan logiikan yksittäiseen funktioon komponentin ulkopuolelle, jota kutsutaan “reduktoriksi”.

Reduktorit ovat erilainen tapa käsitellä tilaa. Voit siirtyä useStatesta useReduceriin kolmessa vaiheessa:

  1. Siirrä tilan asettaminen toiminnon lähettämiseksi.
  2. Kirjoita reduktorifunktio.
  3. Käytä reduktoria komponentistasi.

1. Vaihe: Päivitä tilan asettaminen toiminnon lähettämiseksi

Tapahtumakäsittelijäsi määrittävät mitä tehdä asettamalla tilan:

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

Poista kaikki tilan asettamisen logiikka. Jäljelle jää kolme tapahtumankäsittelijää:

  • handleAddTask(text) kutsutaan kun käyttäjä painaa “Add”.
  • handleChangeTask(task) kutsutaan kun käyttäjä vaihtaa tehtävän tilaa tai painaa “Save”.
  • handleDeleteTask(taskId) kutsutaan kun käyttäjä painaa “Delete”.

Tilan hallinta reduktoreilla on hieman erilaista kuin suoraan tilan asettaminen. Sen sijaan että kerrot Reactille “mitä tehdä” asettamalla tilan, määrität “mitä käyttäjä juuri teki” lähettämällä “toimintoja” tapahtumankäsittelijöistäsi. (Tilan päivityslogiikka asuu muualla!) Joten sen sijaan että “asettaisit tasksin” tapahtumankäsittelijässä, lähettäisit “lisätty/muokattu/poistettu tehtävä” -toiminnon. Tämä on kuvaavampi käyttäjän tarkoituksesta.

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

Olio jonka välität dispatchlle on nimeltään “toiminto”:

function handleDeleteTask(taskId) {
dispatch(
// "action" olio:
{
type: 'deleted',
id: taskId,
}
);
}

Se on tavallinen JavaScript-olio. Päätät mitä sinne laitat, mutta yleensä se pitäisi sisällään vähimmäistiedot mitä tapahtui. (Lisäät dispatch-funktion itse myöhemmin.)

Huomaa

Toiminto-olion muoto voi olla mitä tahansa.

Käytännön mukaan, on yleistä antaa sille merkkijono type, joka kuvaa mitä tapahtui, ja lähettää lisätietoja muissa kentissä. type on erityinen komponentille, joten tässä esimerkissä joko 'added' tai 'added_task' olisi hyvä. Valitse nimi, joka kertoo mitä tapahtui!

dispatch({
// erityinen komponentille
type: 'mita_tapahtui',
// muut kentät tulee tänne
});

2. Vaihe: Kirjoita reduktorifunktio

Reduktorifunktio on paikka, johon laitat tilalogiikan. Se ottaa kaksi argumenttia, nykyisen tilan ja toiminnon olion, ja palauttaa seuraavan tilan:

function yourReducer(state, action) {
// palauta seuraava tila Reactille asetettavaksi
}

React asettaa tilaksi sen mitä palautat reduktorista.

Siirtääksesi tilan asettamislogiikan tapahtumankäsittelijöistäsi reduktorifunktioon tässä esimerkissä, sinun pitää:

  1. Määritä nykyinen tila (tasks) ensimmäisenä argumenttina.
  2. Määritä action-olio toisena argumenttina.
  3. Palauta seuraava tila reduktorista (jonka React asettaa tilaksi).

Tässä on kaikki tilanasettamislogiikka siirretty reduktorifunktioon:

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('Unknown action: ' + action.type);
}
}

Koska reduktorifunktio ottaa tilan (tasks) argumenttina, voit määritellä sen komponentin ulkopuolella. Tämä pienentää sisennystasoa ja voi tehdä koodistasi helpommin luettavaa.

Huomaa

Koodi yllä käyttää if/else -lauseita, mutta on tapana käyttää switch-lauseita reduktoreissa. Tulos on sama, mutta switch-lauseet ovat helpompi lukea silmäyksellä.

Käytämme niitä tässä dokumentaatiossa loppuun asti seuraavasti:

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('Unknown action: ' + action.type);
}
}
}

Suosittelemme käärimään jokaisen case-lohkon { ja } aaltosulkeisiin, jotta eri case-lohkoissa määritellyt muuttujat eivät sekoitu keskenään. Lisäksi case-lohkossa pitäisi yleensä loppua return-lauseella. Jos unohdat return-lauseen, koodi “tippuu” seuraavaan case-lohkoon, mikä voi johtaa virheisiin!

Jos et ole vielä mukavuusalueellasi switch-lauseilla, if/else on täysin ok.

Syväsukellus

Miksi reduktoreita kutsutaan tällä tavalla?

Vaikka reduktoreita voidaan käyttää koodin vähentämiseen komponenteissa, ne nimetään reduce()-toiminnon mukaan, jota voit suorittaa taulukoilla.

reduce()-toiminnolla voit ottaa taulukon ja “kerätä” yhden arvon monista:

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

Funktio jonka välität reduce -funktioon kutsutaan “reduktoriksi”. Se ottaa tuloksen tähän mennessä ja nykyisen kohteen ja palauttaa seuraavan tuloksen. React-reduktorit ovat samaa ideaa: ne ottaa tilan tähän mennessä ja toiminnon ja palauttaa seuraavan tilan. Tällä tavalla ne keräävät toimintoja ajan myötä tilaan.

Voisit jopa käyttää reduce()-metodia initialState- ja actions-taulukoiden kanssa lopullisen tilan laskemiseen välittämällä sille reduktorifunktion:

import tasksReducer from './tasksReducer.js';

let initialState = [];
let actions = [
  {type: 'added', id: 1, text: 'Visit Kafka Museum'},
  {type: 'added', id: 2, text: 'Watch a puppet show'},
  {type: 'deleted', id: 1},
  {type: 'added', id: 3, text: 'Lennon Wall pic'},
];

let finalState = actions.reduce(tasksReducer, initialState);

const output = document.getElementById('output');
output.textContent = JSON.stringify(finalState, null, 2);

Sinun ei välttämättä tarvitse tehdä tätä itse, mutta tämä on samankaltaista kuin mitä React tekee.

3. Vaihe: Käytä reduktoria komponentistasi

Lopuksi, sinun täytyy liittää tasksReducer komponenttiisi. Tuot useReducer Hookki Reactista:

import { useReducer } from 'react';

Sitten voit korvata useStaten:

const [tasks, setTasks] = useState(initialTasks);

useReducerlla kuten tässä:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

useReducer hookki on samankaltainen kuin useState-hookki. Sinun täytyy antaa sille alkuarvo niin se palauttaa tilan arvon ja tavan asettaa tilan (tässä tapauksessa dispatch-funktio). Mutta se on hieman erilainen.

useReducer hookki ottaa kaksi argumenttia:

  1. Reduktorifunktion
  2. Alkutilan

Ja se palauttaa:

  1. Tilan
  2. Toiminnonlähetysfunktion (joka “dispatchaa” käyttäjän toimintoja reduktoriin)

Nyt se on täysin kytketty! Tässä reduktori on määritelty komponentin tiedoston alaosassa:

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>Prague itinerary</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('Unknown action: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

Jos haluat, voit siirtää reduktorin eri tiedostoon:

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>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

Komponentin logiikka on helpompi lukea, kun erotat asiat kuten tämä. Nyt tapahtumankäsittelijät määrittelevät vain mitä tapahtui lähettämällä toimintoja, ja reduktori määrittelee miten tila päivittyy vastaamaan niitä.

useState ja useReducer vertailussa

Reduktorit eivät ole ilman haittoja! Tässä on muutamia tapoja verrata niitä:

  • Koodin koko: Yleensä useState -käyttöönottoon tarvitaan vähemmän koodia. useReducer -käyttöönottoon tarvitaan sekä reduktori-funktio että toimintoja. Kuitenkin useReducer voi auttaa vähentämään koodia, jos monet tapahtumankäsittelijät muuttavat tilaa samalla tavalla.
  • Luettavuus: useState on erittäin helppo lukea, kun tilan päivitykset ovat yksinkertaisia. Kun niistä tulee monimutkaisempia, ne voivat paisuttaa komponentin koodia ja tehdä sen vaikeaksi lukea. Tässä tapauksessa useReducerlla voit erottaa selkeästi päivityslogiikan toiminnon itse tapahtumasta.
  • Debuggaus: Kun sinulla on bugi useState hookin kanssa, se saattaa olla hankalaa selvittää missä tila asetettiin väärin ja miksi. useReducer hookin kanssa voit lisätä reduktorifunktioon lokikirjauksen konsoliin, jotta näet jokaisen tilan päivityksen ja miksi se tapahtui (minkä actionin perusteella). Jos jokainen action on oikein, tiedät, että virhe on reduktorifunktion logiikassa. Kuitenkin sinun on käytävä läpi enemmän koodia kuin useState hookin kanssa.
  • Testaaminen: A reducer is a pure function that doesn’t depend on your component. This means that you can export and test it separately in isolation. While generally it’s best to test components in a more realistic environment, for complex state update logic it can be useful to assert that your reducer returns a particular state for a particular initial state and action.
  • Testaaminen: Reduktori on puhdas funktio, joka ei riipu komponentistasi. Tämä tarkoittaa, että voit exportata ja testata sitä erikseen eristettynä. Yleensä parasta on testata komponentteja realistisemmassa ympäristössä, mutta monimutkaisen tilan päivityslogiikan tapauksessa voi olla hyödyllistä varmistaa, että reduktori palauttaa tietyn tilan aina tiettyyn alkutilaan ja toimintoon.
  • Oma mieltymys: Toiset pitävät reduktoreista, toiset eivät. Se on ok. Se on mielipidekysymys. Voit aina muuttaa useState ja useReducer koodit toisistaan: ne ovat yhtä hyviä!

Suosittelemme käyttämään reduktoria, jos kohtaat usein virheitä tilan päivityksissä jossakin komponentissa ja haluat lisätä koodiin enemmän rakennetta. Et tarvitse reduktoria kaikkeen: käytä vapaasti! Voit jopa käyttää useState ja useReducer hookkeja samassa komponentissa.

Hyvän reduktorin kirjoittaminen

Pidä nämä kaksi vinkkiä mielessäsi, kun kirjoitat reduktoreita:

  • Reduktorien on oltava puhtaita. Samanlailla kuin tilan päivitysfunktiot, reduktorit suoritetaan renderöinnin aikana! (Toiminnot ovat jonossa seuraavaan renderöintiin.) Tämä tarkoittaa, että reduktorien on oltava puhtaita—samat lähtötiedot tuottavat aina saman lopputuloksen. Niiden ei tulisi lähettää kutsuja, ajastaa aikakatkaisuja, tai tehdä sivuvaikutuksia (toimintoja, jotka vaikuttavat asioihin komponentin ulkopuolella). Niiden tulisi päivittää olioita ja taulukoita ilman mutaatiota.
  • Jokainen toiminto kuvastaa yksittäisen käyttäjän vuorovaikutusta, vaikka se johtaisi useampiin muutoksiin datassa. Esimerkiksi, jos käyttäjä painaa “Reset” painiketta lomakkeessa, jossa on viisi kenttää hallittuna reduktorilla, on järkevämpää lähettää yksi reset_form toiminto kuin viisi erillistä set_field toimintoa. Jos lokitetaan jokainen toiminto reduktorissa, lokin tulisi olla riittävän selkeä, jotta voit rakentaa mitä vuorovaikutuksia tai vastauksia tapahtui missä järjestyksessä. Tämä auttaa virheenkorjauksessa!

Tiiviin reduktorin kirjoittaminen Immerin avulla

Juuri kuten olioiden ja taulukoiden päivittämisen kanssa, voit käyttää Immer kirjastoa reduktoreiden kirjoittamiseen tiiviimmäksi. Tässä, useImmerReducer:n avulla voit muuttaa tilan push tai arr[i] = määrityksellä:

import { useImmerReducer } from 'use-immer';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

function tasksReducer(draft, action) {
  switch (action.type) {
    case 'added': {
      draft.push({
        id: action.id,
        text: action.text,
        done: false,
      });
      break;
    }
    case 'changed': {
      const index = draft.findIndex((t) => t.id === action.task.id);
      draft[index] = action.task;
      break;
    }
    case 'deleted': {
      return draft.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

export default function TaskApp() {
  const [tasks, dispatch] = useImmerReducer(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>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

Reduktorien on oltava puhtaita, joten niiden ei tulisi mutatoida tilaa. Mutta Immer tarjoaa sinulle erikoisen draft-olion, jota voi turvallisesti mutatoida. Immer luo taustalla kopion tilasta muutoksilla, jotka teit draft:iin. Tämän takia useImmerReducer:n hallinnoimat reduktorit voivat mutatoida niiden ensimmäistä argumenttiaan ja ei tarvitse palauttaa tilaa.

Kertaus

  • useStatesta useReduceriin muuttaminen:
    1. Lähetä toimintoja tapahtumankäsittelijöistä.
    2. Kirjoita reduktorifunktio, joka palauttaa seuraavan tilan annetulle tilalle ja toiminnolle.
    3. Korvaa useState useReducer:lla.
  • Reduktorit vaativat hieman enemmän koodia, mutta ne auttavat virheenetsinnässä ja testauksessa.
  • Reduktorien on oltava puhtaita.
  • Kullakin toiminnolla on yksi käyttäjän vuorovaikutus.
  • Käytä Immeriä, jos haluat kirjoittaa reduktorit muuttavalla tyylillä.

Haaste 1 / 4:
Lähetä toimintoja tapahtumankäsittelijöistä

Tällä hetkellä, tapahtumankäsittelijät ContactList.js ja Chat.js tiedostoissa sisältävät // TODO kommentteja. Tämän takia kirjoittaminen ei toimi ja nappien painaminen ei vaihda valittua vastaanottajaa.

Korvaa nämä kaksi // TODO kommenttia koodilla, joka lähettää dispatchllä toimintoja. Katso toiminnon odotettu muoto ja tyyppi reduktorista, messengerReducer.js tiedostosta. Reduktori on jo kirjoitettu, joten sinun ei tarvitse muuttaa sitä. Sinun tarvitsee vain lähettää toimintoja ContactList.js ja Chat.js tiedostoissa.

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'},
];