Mengekstraksi Logika State ke Reducer

Komponen dengan banyak pembaruan state yang tersebar di banyak event handlers bisa menjadi sangat membingungkan. Untuk kasus seperti ini, Anda dapat menggabungkan semua logika pembaruan state di luar komponen Anda dalam satu fungsi, yang disebut sebagai reducer.

Anda akan mempelajari

  • Apa itu fungsi reducer
  • Bagaimana cara untuk migrasi dari fungsi useState menjadi useReducer
  • Kapan menggunakan fungsi reducer
  • Bagaimana cara menulis fungsi reducer dengan baik

Mengkonsolidasikan logika state menggunakan reducer

Saat komponen-komponen Anda semakin kompleks, hal ini mengakibatkan berbagai cara memperbarui state komponen dalam kode menjadi sulit untuk dilihat secara sekilas. Contoh, komponen TaskApp di bawah menyimpan senarai tasks dalam state dan menggunakan tiga handler untuk menambahkan, menghapus dan mengubah tasks:

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: 'Mengunjungi Museum Kafka', done: true},
  {id: 1, text: 'Tonton pertunjukan boneka', done: false},
  {id: 2, text: 'Foto Lennon Wall', done: false},
];

Setiap event handler pada komponen TaskApp akan memangil fungsi setTask untuk melakukan pembaharuan state. Saat komponen ini bertumbuh semakin besar, logika state akan semakin rumit. Untuk mengurangi kompleksitas dan menyimpan semua logika Anda di satu tempat yang mudah diakses. Anda dapat memindahkan logika state tersebut ke sebuah fungsi di luar komponen Anda, yang disebut “reducer”.

Reducer merupakan sebuah alternatif untuk menangani state. Anda dapat migrasi dari useState ke useReducer dalam tiga langkah:

  1. Pindah dari menyetel state menjadi men-dispatch action
  2. Tulis fungsi reducer.
  3. Gunakan reducer pada komponen Anda.

Langkah 1: Pindah dari menyetel state menjadi men-dispatch action

Event handler Anda sekarang menentukan apa yang harus dilakukan dengan menyetel state:

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

Hapus semua logika untuk mengatur state. Maka yang tersisa adalah tiga event handlers:

  • handleAddTask(text) akan dipanggil ketika pengguna menekan tombol “Add”.
  • handleChangeTask(task) akan dipanggil ketika pengguna matikan sebuah task atau menekan tombol “Save”.
  • handleDeleteTask(taskId) akan dipanggil ketika pengguna menekan tombol “Delete”.

Mengelola state dengan fungsi reducer akan sedikit berbeda dari memanggil fungsi penetap state secara langsung. Alih-alih memberi tahu React “apa yang harus dilakukan” dengan menetapkan state, Anda merincis “apa yang baru saja dilakukan pengguna” dengan mengirim “aksi” dari event handler Anda. (Logika pembaruan state akan berada di tempat lain!) Jadi, alih-alih “mengatur tasks” melalui event handler, Anda mengirim aksi “menambahkan/mengubah/menghapus task”. Cara ini lebih deskriptif untuk mengetahui intensi aksi pengguna.

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

Objek yang anda masukan ke dispatch dapat disebut sebagai “aksi”:

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

Contoh diatas merupakan objek JavaScript biasa. Anda dapat menentukan objek apapun yang akan dimasukkan ke dalamnya, tetapi umumnya harus berisi sebuah objek yang setidaknya berisi informasi Apa yang terjadi. (Anda akan menambahkan fungsi dispatch itu sendiri di langkah selanjutnya.)

Catatan

Objek aksi bisa memiliki berbagai macam bentuk.

Secara konvensi, umumnya objek aksi diberikan sebuah properti type bertipe string yang menjelaskan aksi apa yang telah terjadi. Dan menambahkan informasi tambahan pada properti lainnya. Properti type ini khusus untuk komponen, maka untuk contoh ini nilai 'added' atau 'added_task' akan baik-baik saja diberikan pada properti type. Tentukan nama yang dapat menjelaskan aksi apa yang terjadi!

dispatch({
// khusus untuk komponen
type: 'what_happened',
// properti lain disini
});

Langkah 2: Tulis fungsi reducer

Fungsi reducer merupakan tempat dimana Anda meletakkan logika state. Fungsi reducer menerima dua argumen yaitu state sekarang dan object aksi, dan mengembalikan state berikutnya:

function yourReducer(state, action) {
// mengembalikan state berikutnya untuk ditetapkan oleh React
}

React akan menetapkan state yang dikembalikan oleh reducer Anda.

Untuk memindahkan logika fungsi penetap state dari event handler Anda menjadi fungsi reducer pada contoh berikut, Anda akan:

  1. Deklarasikan state(tasks) saat ini sebagai argumen pertama.
  2. Deklarasikan objek action sebagai argumen kedua.
  3. Mengembalikan state berikutnya melalui reducer (di mana React akan mengatur statusnya).

Berikut merupakan logika funsi penetap state yang dimigrasikan ke fungsi reducer:

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

Karena fungsi reducer menerima state (tasks) sebagai sebuah argument, Anda dapat mendeklarasikan fungsi reducer di luar komponen Anda. Dengan ini kode yang Anda tulis tingkat indentasinya akan berkurang dan akan lebih mudah dibaca.

Catatan

Kode di atas menggunakan pernyataan if/else, namun secara konvensi ketika menggunakan fungsi reducer Anda harus menggunakan pernyataan switch pada fungsi tersebut. Secara hasil akan sama, tetapi akan lebih mudah dibaca secara sekilas.

Kami akan menggunakan pernyataan switch sepanjang sisa dokumentasi ini sebagai berikut:

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

Kami merekomendasikan sebaiknya menggabungkan masing-masing blok case ke dalam kurung kurawal { dan } sehingga variabel yang dideklarasikan di dalam case berbeda tidak bentrok satu sama lain. Dan juga, sebuah case biasanya diakhiri dengan return. Jika Anda lupa mengakhiri dengan return, kode akan lanjut ke case berikutnya, yang akan menyebabkan keselahan.

Jika Anda belum nyaman menggunakan pernyataan switch, menggunakan pernyataan if/else itu tidak apa-apa.

Pendalaman

Mengapa fungsi reducer disebut seperti ini?

Meskipun reducer dapat “mengurangi” jumlah kode di dalam komponen Anda, sebenarnya mereka dinamakan setelah operasi reduce() yang dapat dilakukan pada senarai.

Operasi reduce() memungkinkan Anda untuk mengambil sebuah senarai dan “mengakumulasi” sebuah nilai tunggal dari banyak nilai:

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

Fungsi yang Anda berikan ke reduce dikenal sebagai “reducer”. Fungsi tersebut mengambil hasil sejauh ini dan item saat ini, kemudian mengembalikan hasil berikutnya. Reducer di React adalah contoh dari ide yang sama: mereka mengambil state sejauh ini dan aksi, lalu mengembalikan state berikutnya. Dengan cara ini, reducer mengakumulasi aksi dari waktu ke waktu ke dalam state.

Anda bahkan dapat menggunakan metode reduce() dengan initialState dan sebuah senarai aksi untuk menghitung state akhir dengan melewatkan fungsi reducer ke dalamnya:

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

Anda mungkin tidak perlu melakukannya sendiri, tetapi ini mirip dengan apa yang dilakukan oleh React!

Langkah 3: Gunakan reducer pada komponen Anda

Terakhir, Anda perlu menghubungkan fungsi tasksReducer ke komponen Anda. Impor fungsi hook useReducer dari React:

import { useReducer } from 'react';

Kemudian Anda dapat mengganti fungsi useState:

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

dengan fungsi useReducer seperti ini:

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

Fungsi hook useReducer mirip dengan fungsi useState—Anda harus melewatkan state awal ke dalamnya dan ia mengembalikan nilai stateful dan cara untuk mengatur state (dalam kasus ini, fungsi dispatch). Namun, sedikit berbeda.

Fungsi hook useReducer mengambil dua argumen:

  1. Fungsi reducer.
  2. State awal.

Dan fungsi hook useReducer mengembalikan:

  1. Nilai statefull.
  2. Fungsi dispatch (untuk “men-dispatch” aksi pengguna ke fungsi reducer).

Sekarang fungsi reducer sudah sepenuhnya terhubung! Di sini, fungsi reducer dinyatakan di bagian bawah file komponen:

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

Hal ini opsional, namun jika Anda ingin melakukannya, Anda bahkan dapat memindahkan reducer ke file yang berbeda:

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

Logika komponen akan dapat lebih mudah dibaca ketika Anda memisahkan aspek seperti ini. Sekarang, event handler hanya menentukan apa yang terjadi dengan mengirimkan aksi, dan fungsi reducer menentukan bagaimana state diperbarui sebagai respons terhadapnya.

Membandingkan useState dan useReducer

Reducers tidaklah tanpa kekurangan! Berikut adalah beberapa cara untuk membandingkannya:

  • Ukuran kode: Secara umum, dengan useState kamu harus menulis lebih sedikit kode di awal. Dengan useReducer, kamu harus menulis baik fungsi reducer dan dispatch actions. Namun, useReducer dapat membantu mengurangi jumlah kode jika banyak event handler memodifikasi state dengan cara yang serupa.
  • Keterbacaan: useState sangat mudah dibaca ketika pembaruan state sederhana. Ketika pembaruan semakin kompleks, mereka dapat membesarkan kode komponen dan sulit untuk dipindai. Dalam hal ini, useReducer memungkinkan kamu untuk memisahkan dengan jelas bagaimana logika pembaruan dipisahkan dari apa yang terjadi pada event handler.
  • Debugging: Ketika kamu memiliki bug dengan useState, sulit untuk mengetahui di mana state diatur dengan tidak benar, dan mengapa. Dengan useReducer, kamu dapat menambahkan log konsol ke reducer kamu untuk melihat setiap pembaruan state, dan mengapa itu terjadi (karena aksi apa). Jika setiap aksi benar, kamu akan tahu bahwa kesalahan ada di logika reducer itu sendiri. Namun, kamu harus melalui kode yang lebih banyak daripada useState.
  • Testing: Sebuah reducer adalah fungsi murni yang tidak bergantung pada komponen kamu. Ini berarti kamu dapat mengekspor dan mengujinya secara terpisah secara isolasi. Meskipun secara umum lebih baik untuk menguji komponen dalam lingkungan yang lebih realistis, untuk logika pembaruan state yang kompleks dapat berguna untuk menegaskan bahwa reducer kamu mengembalikan state tertentu untuk state awal dan aksi tertentu.
  • Preferensi pribadi: Beberapa orang suka reducers, yang lain tidak. Itu tidak apa-apa. Ini hanya masalah preferensi. Kamu selalu dapat mengonversi antara fungsi useState dan useReducer bolak-balik: mereka setara!

Kami merekomendasikan menggunakan reducer jika kamu sering menghadapi bug karena pembaruan state yang salah dibeberapa komponen, dan ingin memperkenalkan lebih banyak struktur pada kode-nya. Kamu tidak harus menggunakan fungsi reducers untuk semuanya: bebas untuk mencampur dan mencocokkan! Kamu bahkan dapat menggunakan useState dan useReducer di komponen yang sama.

Menulis fungsi reduksi dengan baik

Ingatlah dua tips ini saat menulis fungsi reducers:

  • Reducer harus murni (pure). Sama seperti fungsi updater state, reducer dijalankan selama proses rendering! (Aksi diantre sampai render selanjutnya.) Ini berarti bahwa reducer harus murni - input yang sama selalu menghasilkan output yang sama. Mereka tidak boleh mengirim permintaan, menjadwalkan waktu tunggu, atau melakukan efek samping (operasi yang memengaruhi hal-hal di luar komponen). Mereka harus memperbarui objek dan senarai tanpa mutasi.
  • Setiap aksi menjelaskan satu interaksi pengguna, meskipun itu mengakibatkan beberapa perubahan pada data. Sebagai contoh, jika pengguna menekan “Reset” pada formulir dengan lima field yang dikelola oleh reducer, lebih baik untuk mengirimkan satu aksi reset_form daripada lima aksi set_field terpisah. Jika Anda mencatat setiap aksi dalam fungsi reducer, log tersebut harus cukup jelas bagi Anda untuk merekonstruksi interaksi atau respon apa yang terjadi dalam urutan apa. Ini membantu dalam proses debugging!

Menulis Reducer yang singkat dengan Immer

Sama seperti memperbarui objek dan senarai pada state biasa, Anda dapat menggunakan pustaka Immer untuk membuat reducer lebih ringkas. Di sini, useImmerReducer memungkinkan Anda memutasi state dengan push atau arr[i] = assignment:

{
  "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": {}
}

Fungsi reducers harus bersifat murni, sehingga tidak boleh mengubah state. Tetapi Immer memberikan objek khusus draft yang aman untuk dimutasi. Di bawah kap mesin, Immer akan membuat salinan dari state Anda dengan perubahan yang dibuat pada draft. Inilah sebabnya mengapa reducer yang dikelola oleh useImmerReducer dapat memutasi argumen pertama mereka dan tidak perlu mengembalikan state.

Rekap

  • Untuk mengkonversi dari useState ke useReducer:
    1. Kirim aksi dari event handler.
    2. Tulis fungsi reducer yang mengembalikan state selanjutnya untuk state dan aksi yang diberikan.
    3. Ganti useState dengan useReducer.
  • Reducer membutuhkan Anda untuk menulis sedikit lebih banyak kode, tetapi membantu dengan debugging dan pengujian.
  • Fungsi reducer harus murni.
  • Setiap aksi menggambarkan satu interaksi pengguna.
  • Gunakan Immer jika Anda ingin menulis fungsi reducer dalam gaya yang dapat dimutasi.

Tantangan 1 dari 4:
Meneruskan aksi dari event handlers

Meneruskan tindakan dari penangan acara

Saat ini, penangan acara di ContactList.js dan Chat.js memiliki komentar // TODO. Inilah mengapa mengetik ke input tidak berfungsi, dan mengklik tombol tidak mengubah penerima yang dipilih.

Gantikan dua // TODO ini dengan kode untuk meneruskan tindakan yang sesuai. Untuk melihat bentuk yang diharapkan dan jenis tindakan, periksa pengurang dalam messengerReducer.js. Pengurang sudah ditulis sehingga Anda tidak perlu mengubahnya. Anda hanya perlu meneruskan tindakan di ContactList.js dan 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'},
];