Trích xuất State Logic vào một Reducer

Những component có nhiều cập nhật state trải rộng khắp nhiều event handler có thể trở nên phức tạp quá mức. Trong những trường hợp này, bạn có thể hợp nhất tất cả logic cập nhật state bên ngoài component của bạn vào một function duy nhất, được gọi là reducer.

Bạn sẽ được học

  • Function reducer là gì
  • Cách refactor từ useState sang useReducer
  • Khi nào nên sử dụng reducer
  • Cách viết reducer tốt

Hợp nhất logic state với reducer

Khi những component của bạn phát triển theo độ phức tạp, có thể trở nên khó khăn hơn để nhìn thoáng qua tất cả những cách khác nhau mà state của component được cập nhật. Ví dụ, component TaskApp bên dưới giữ một mảng tasks trong state và sử dụng ba event handler khác nhau để thêm, xóa và chỉnh sửa task:

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

Mỗi event handler của nó gọi setTasks để cập nhật state. Khi component này phát triển, cũng như số lượng logic state rải rác khắp nó. Để giảm độ phức tạp này và giữ tất cả logic của bạn ở một nơi dễ truy cập, bạn có thể di chuyển logic state đó thành một function duy nhất bên ngoài component của bạn, được gọi là “reducer”.

Reducer là một cách khác để xử lý state. Bạn có thể di chuyển từ useState sang useReducer trong ba bước:

  1. Di chuyển từ việc setting state sang dispatching action.
  2. Viết một function reducer.
  3. Sử dụng reducer từ component của bạn.

Bước 1: Di chuyển từ setting state sang dispatching action

Những event handler hiện tại của bạn chỉ định phải làm gì bằng cách setting 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));
}

Loại bỏ tất cả logic setting state. Những gì bạn còn lại là ba event handler:

  • handleAddTask(text) được gọi khi người dùng nhấn “Add”.
  • handleChangeTask(task) được gọi khi người dùng toggle một task hoặc nhấn “Save”.
  • handleDeleteTask(taskId) được gọi khi người dùng nhấn “Delete”.

Quản lý state với reducer hơi khác so với việc setting state trực tiếp. Thay vì nói với React “phải làm gì” bằng cách setting state, bạn chỉ định “người dùng vừa làm gì” bằng cách dispatching “action” từ những event handler của bạn. (Logic cập nhật state sẽ tồn tại ở nơi khác!) Vì vậy thay vì “setting tasks” thông qua một event handler, bạn đang dispatching một action “added/changed/deleted a task”. Điều này mô tả ý định của người dùng một cách rõ ràng hơn.

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

Object bạn truyền cho dispatch được gọi là “action”:

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

Đó là một object JavaScript thông thường. Bạn quyết định đặt gì vào đó, nhưng nói chung nó nên chứa thông tin tối thiểu về điều gì đã xảy ra. (Bạn sẽ thêm chính function dispatch ở bước sau.)

Note

Một object action có thể có bất kỳ hình dạng nào.

Theo quy ước, việc thường thấy là đặt cho nó một string type mô tả điều gì đã xảy ra, và truyền bất kỳ thông tin bổ sung nào trong những field khác. type cụ thể cho một component, vì vậy trong ví dụ này 'added' hoặc 'added_task' đều ổn. Chọn một tên nói lên điều gì đã xảy ra!

dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});

Bước 2: Viết một function reducer

Function reducer là nơi bạn sẽ đặt logic state của mình. Nó nhận hai tham số, state hiện tại và object action, và nó trả về state tiếp theo:

function yourReducer(state, action) {
// return next state for React to set
}

React sẽ set state thành những gì bạn return từ reducer.

Để di chuyển logic setting state từ những event handler của bạn sang một function reducer trong ví dụ này, bạn sẽ:

  1. Khai báo state hiện tại (tasks) như tham số đầu tiên.
  2. Khai báo object action như tham số thứ hai.
  3. Return state tiếp theo từ reducer (mà React sẽ set state thành).

Đây là tất cả logic setting state được di chuyển sang một function 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);
}
}

Bởi vì function reducer nhận state (tasks) như một tham số, bạn có thể khai báo nó bên ngoài component của bạn. Điều này giảm mức độ thụt lề và có thể làm cho code của bạn dễ đọc hơn.

Note

Code trên sử dụng câu lệnh if/else, nhưng việc thường thấy là sử dụng câu lệnh switch bên trong reducer. Kết quả giống nhau, nhưng có thể dễ đọc hơn khi nhìn thoáng qua câu lệnh switch.

Chúng ta sẽ sử dụng chúng trong phần còn lại của tài liệu này như sau:

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

Chúng tôi khuyến nghị bao bọc mỗi khối case vào dấu ngoặc nhọn {} để các biến được khai báo bên trong những case khác nhau không xung đột với nhau. Ngoài ra, một case thường nên kết thúc bằng return. Nếu bạn quên return, code sẽ “rơi qua” đến case tiếp theo, có thể dẫn đến lỗi!

Nếu bạn chưa quen với câu lệnh switch, sử dụng if/else hoàn toàn ổn.

Tìm hiểu sâu

Tại sao reducer được gọi như vậy?

Mặc dù reducer có thể “giảm” số lượng code bên trong component của bạn, thực tế chúng được đặt tên theo phép toán reduce() mà bạn có thể thực hiện trên mảng.

Phép toán reduce() cho phép bạn lấy một mảng và “tích lũy” một giá trị duy nhất từ nhiều giá trị:

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

Function bạn truyền cho reduce được gọi là “reducer”. Nó nhận kết quả hiện tạiphần tử hiện tại, sau đó nó trả về kết quả tiếp theo. React reducer là một ví dụ của cùng một ý tưởng: chúng nhận state hiện tạiaction, và trả về state tiếp theo. Theo cách này, chúng tích lũy những action theo thời gian thành state.

Bạn thậm chí có thể sử dụng method reduce() với một initialState và một mảng actions để tính toán state cuối cùng bằng cách truyền function reducer của bạn vào nó:

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

Có thể bạn sẽ không cần phải tự làm điều này, nhưng đây là tương tự với những gì React làm!

Bước 3: Sử dụng reducer từ component của bạn

Cuối cùng, bạn cần kết nối tasksReducer với component của bạn. Import Hook useReducer từ React:

import { useReducer } from 'react';

Sau đó bạn có thể thay thế useState:

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

bằng useReducer như thế này:

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

Hook useReducer tương tự như useState—bạn phải truyền cho nó một initial state và nó trả về một stateful value và một cách để set state (trong trường hợp này, function dispatch). Nhưng nó hơi khác một chút.

Hook useReducer nhận hai tham số:

  1. Một function reducer
  2. Một initial state

Và nó trả về:

  1. Một stateful value
  2. Một function dispatch (để “dispatch” những action của người dùng tới reducer)

Bây giờ nó đã được kết nối đầy đủ! Ở đây, reducer được khai báo ở cuối file component:

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

Nếu bạn muốn, bạn thậm chí có thể di chuyển reducer sang một file khác:

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

Component logic có thể dễ đọc hơn khi bạn tách biệt các mối quan tâm như thế này. Bây giờ những event handler chỉ chỉ định điều gì đã xảy ra bằng cách dispatching action, và function reducer xác định cách state cập nhật để phản hồi chúng.

So sánh useStateuseReducer

Reducer không phải là không có nhược điểm! Đây là một vài cách bạn có thể so sánh chúng:

  • Kích thước code: Nói chung, với useState bạn phải viết ít code hơn từ đầu. Với useReducer, bạn phải viết cả function reducer dispatch action. Tuy nhiên, useReducer có thể giúp cắt giảm code nếu nhiều event handler chỉnh sửa state theo cách tương tự.
  • Khả năng đọc: useState rất dễ đọc khi những cập nhật state đơn giản. Khi chúng trở nên phức tạp hơn, chúng có thể làm phình to code component của bạn và làm cho nó khó để quét qua. Trong trường hợp này, useReducer cho phép bạn tách biệt một cách sạch sẽ cách thức của logic cập nhật khỏi điều gì đã xảy ra của những event handler.
  • Debugging: Khi bạn có bug với useState, có thể khó để nói ở đâu state được set không chính xác, và tại sao. Với useReducer, bạn có thể thêm console log vào reducer của mình để thấy mọi cập nhật state, và tại sao nó xảy ra (do action nào). Nếu mỗi action đúng, bạn sẽ biết rằng lỗi nằm trong chính logic reducer. Tuy nhiên, bạn phải bước qua nhiều code hơn so với useState.
  • Testing: Reducer là một pure function không phụ thuộc vào component của bạn. Điều này có nghĩa là bạn có thể export và test nó riêng biệt trong cô lập. Mặc dù nói chung tốt nhất là test component trong môi trường thực tế hơn, đối với logic cập nhật state phức tạp, có thể hữu ích để khẳng định rằng reducer của bạn trả về một state cụ thể cho một initial state và action cụ thể.
  • Sở thích cá nhân: Một số người thích reducer, những người khác thì không. Điều đó ổn. Đó là vấn đề sở thích. Bạn luôn có thể chuyển đổi giữa useStateuseReducer qua lại: chúng tương đương!

Chúng tôi khuyên sử dụng reducer nếu bạn thường gặp bug do cập nhật state không chính xác trong một số component, và muốn giới thiệu thêm cấu trúc vào code của nó. Bạn không phải sử dụng reducer cho mọi thứ: cứ thoải mái kết hợp và phối hợp! Bạn thậm chí có thể useStateuseReducer trong cùng một component.

Viết reducer tốt

Hãy ghi nhớ hai mẹo này khi viết reducer:

  • Reducer phải là pure. Tương tự như state updater function, reducer chạy trong quá trình render! (Action được xếp hàng cho đến lần render tiếp theo.) Điều này có nghĩa là reducer phải là pure—cùng input luôn dẫn đến cùng output. Chúng không nên gửi request, lên lịch timeout, hoặc thực hiện bất kỳ side effect nào (phép toán ảnh hưởng đến những thứ bên ngoài component). Chúng nên cập nhật objectmảng mà không có mutation.
  • Mỗi action mô tả một tương tác người dùng duy nhất, ngay cả khi điều đó dẫn đến nhiều thay đổi trong dữ liệu. Ví dụ, nếu người dùng nhấn “Reset” trên một form với năm field được quản lý bởi reducer, việc dispatch một action reset_form có ý nghĩa hơn so với năm action set_field riêng biệt. Nếu bạn log mọi action trong reducer, log đó nên đủ rõ ràng để bạn tái tạo những tương tác hoặc phản hồi nào đã xảy ra theo thứ tự nào. Điều này giúp với debugging!

Viết reducer ngắn gọn với Immer

Giống như với cập nhật objectmảng trong state thông thường, bạn có thể sử dụng thư viện Immer để làm cho reducer ngắn gọn hơn. Ở đây, useImmerReducer cho phép bạn mutate state với push hoặc gán arr[i] =:

{
  "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 phải là pure, vì vậy chúng không nên mutate state. Nhưng Immer cung cấp cho bạn một object draft đặc biệt mà an toàn để mutate. Bên dưới, Immer sẽ tạo một bản copy của state của bạn với những thay đổi bạn đã thực hiện trên draft. Đây là lý do tại sao những reducer được quản lý bởi useImmerReducer có thể mutate tham số đầu tiên của chúng và không cần return state.

Tóm tắt

  • Để chuyển đổi từ useState sang useReducer:
    1. Dispatch action từ những event handler.
    2. Viết một function reducer trả về state tiếp theo cho một state và action đã cho.
    3. Thay thế useState bằng useReducer.
  • Reducer yêu cầu bạn viết nhiều code hơn một chút, nhưng chúng giúp với debugging và testing.
  • Reducer phải là pure.
  • Mỗi action mô tả một tương tác người dùng duy nhất.
  • Sử dụng Immer nếu bạn muốn viết reducer theo kiểu mutating.

Challenge 1 of 4:
Dispatch action từ event handler

Hiện tại, những event handler trong ContactList.jsChat.js có comment // TODO. Đây là lý do tại sao việc gõ vào input không hoạt động, và nhấp vào những button không thay đổi người nhận được chọn.

Thay thế hai // TODO này bằng code để dispatch những action tương ứng. Để thấy hình dạng mong đợi và loại của những action, kiểm tra reducer trong messengerReducer.js. Reducer đã được viết sẵn nên bạn sẽ không cần thay đổi nó. Bạn chỉ cần dispatch những action trong ContactList.jsChat.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'},
];