How to writing Redux Reducers with Immer (P1)

6 min read

Hai hàm createReducer createSlice của Redux Toolkit tự động sử dụng Immer bên trong để giúp bạn viết logic cập nhật bất biến đơn giản hơn bằng cú pháp “mutating”.

Điều này giúp đơn giản hóa hầu hết các triển khai của reducer của bạn.

Vì Immer bản thân nó là một lớp trừu tượng, nên điều quan trọng là phải hiểu tại sao Redux Toolkit sử dụng Immer và cách sử dụng nó đúng cách.

Đầu tiên, bạn cần hiểu, mutable và immutable nghĩa là gì?

Ý nghĩa Immutability trong Redux

“Mutable” có nghĩa là “có thể thay đổi”. Nếu một thứ gì đó là “immutable” (bất biến), nó không thể thay đổi.

JavaScript cho phép bạn thay đổi đối tượng trực tiếp như sau:

const obj = { a: 1, b: 2 };
// vẫn là đối tượng đó ở bên ngoài, nhưng nội dung đã thay đổi
obj.b = 3;

const arr = ['a', 'b'];
// Tương tự, chúng ta có thể thay đổi nội dung của mảng này
arr.push('c');
arr[1] = 'd';

Đây gọi là thay đổi (mutating) đối tượng hoặc mảng. Nó vẫn là tham chiếu của đối tượng hoặc mảng đó trong bộ nhớ, nhưng nội dung bên trong đã thay đổi.

Để cập nhật giá trị một cách bất biến, code của bạn phải tạo các bản sao của các đối tượng/mảng hiện có, sau đó chỉnh sửa các bản sao này.

Chúng ta có thể thực hiện điều này thủ công bằng cách sao chép array/object và thay đổi bản sao đó.

JavaScript cho phép làm điều đó với object với JavaScript và các array method trả về mảng mới.

const obj = {
  a: {
    // Để an toàn cập nhật obj.a.c, chúng ta phải sao chép từng phần
    c: 3,
  },
  b: 2,
};

const obj2 = {
  // sao chép obj
  ...obj,
  // ghi đè a
  a: {
    // sao chép obj.a
    ...obj.a,
    // ghi đè c
    c: 42,
  },
};

const arr = ['a', 'b'];
// Tạo một bản sao mới của arr, thêm "c" vào cuối
const arr2 = arr.concat('c');

// hoặc, chúng ta có thể tạo một bản sao của mảng gốc:
const arr3 = arr.slice();
// và thay đổi bản sao:
arr3.push('c');

Những thao tác này đảm bảo rằng chúng ta không thay đổi trực tiếp đối tượng hoặc mảng gốc, mà thay vào đó tạo các bản sao mới với các thay đổi cần thiết.

Reducers và Immutable Updates

Một trong những quy tắc chính của Redux là reducers không bao giờ được phép thay đổi giá trị trạng thái ban đầu/hiện tại!

(Nguyên văn: “One of the primary rules of Redux is that our reducers are never allowed to mutate the original / current state values!“)

// không nên viết thế này, theo mặc định, điều này sẽ thay đổi trạng thái!
state.value = 123

Có một số lý do bạn không nên thay đổi trạng thái trong Redux như sau:

  • Gây ra lỗi, ví dụ như UI không cập nhật đúng cách để hiển thị các giá trị mới nhất.
  • Gây khó khăn cho việc tại sao và làm thế nào trạng thái được cập nhật.
  • Làm cho việc viết các test case trở nên khó khăn hơn.
  • Phá vỡ khả năng sử dụng “time-travel debugging” một cách chính xác.
  • Đi ngược lại tư tưởng và cách sử dụng partern của Redux.

Vậy nếu chúng ta không thể thay đổi bản gốc thì làm cách nào để trả về trạng thái đã cập nhật?

Đơn giản là sao chép nó và áp dụng mọi thay đổi trên bản sao đó

// nên dùng cách này, vì chúng ta đang tạo ra 1 bản copy
return {
  ...state,
  value: 123,
}

Khi cập nhật dữ liệu lồng nhau một cách bất biến, điều cần thiết là phải nhớ tạo các bản sao ở mọi cấp độ lồng nhau cần sửa đổi. Điều này đảm bảo rằng mỗi cấp độ không thay đổi, duy trì tính toàn vẹn dữ liệu và ngăn ngừa các đột biến ngoài ý muốn.

(Nguyên văn: “This becomes harder when the data is nested. A critical rule of immutable updates is that you must make a copy of every level of nesting that needs to be updated.“)

Ví dụ: Đây là một dữ liệu lồng nhau

function handwrittenReducer(state, action) {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        [action.someId]: {
          ...state.first.second[action.someId],
          fourth: action.someValue,
        },
      },
    },
  }
}

Tuy nhiên, nếu bạn viết logic cập nhật bất biến theo cách thủ công thì nó rất dễ sai và rất phiền phức.

Như vậy, phải làm sao ?

Giải pháp là sử dụng Immer để Immutable Updates

Immer là một thư viện giúp bạn đơn giản hóa quá trình viết logic cập nhật bất biến. Immer cung cấp một hàm gọi là produce, nhận hai đối số: trạng thái ban đầu của bạn và một hàm callback.

Hàm callback nhận được một phiên bản “draft” của trạng thái đó, và bên trong hàm callback, bạn có thể viết mã để thay đổi trực tiếp giá trị của draft. Immer theo dõi tất cả các thay đổi trên draft và sau đó tái tạo lại các thay đổi đó bằng các phiên bản bất biến để tạo ra một kết quả an toàn, bất biến đã được cập nhật:

(Nguyên văn: “Immer provides a function called produce, which accepts two arguments: your original state, and a callback function. The callback function is given a “draft” version of that state, and inside the callback, it is safe to write code that mutates the draft value. Immer tracks all attempts to mutate the draft value and then replays those mutations using their immutable equivalents to create a safe, immutably updated result:”)

import produce from 'immer';

const baseState = [
  {
    todo: 'Learn TypeScript',
    done: true,
  },
  {
    todo: 'Try Immer',
    done: false,
  },
];

const nextState = produce(baseState, draftState => {
  // "mutate" the draft array
  draftState.push({ todo: 'Tweet about it' });
  // "mutate" the nested state
  draftState[1].done = true;
});

console.log(baseState === nextState); // false

console.log(baseState[0] === nextState[0]); // true

console.log(baseState[1] === nextState[1]); // false

Redux Toolkit và Immer có mỗi quan hệ như nào ?

Trong Redux Toolkit, createReducer sử dụng Immer một cách tự động bên trong, do đó việc “mutate” trạng thái bên trong các hàm reducer của trường hợp (case reducer) được truyền vào createReducer là an toàn:

const todosReducer = createReducer([], builder => {
  builder.addCase('todos/todoAdded', (state, action) => {
    // "mutate" the array by calling push()
    state.push(action.payload);
  });
});

Tương tự, createSlice cũng sử dụng createReducer bên trong nên cũng an toàn để “mutate” trạng thái trong đó:

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    todoAdded(state, action) {
      state.push(action.payload);
    },
  },
});

Điều này còn áp dụng khi các hàm reducer được định nghĩa bên ngoài lệnh gọi createSlice hoặc createReducer.

Ví dụ, bạn có thể có một hàm reducer của trường hợp có thể tái sử dụng và “mutate” trạng thái của nó:

const addItemToArray = (state, action) => {
  state.push(action.payload);
};

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    todoAdded: addItemToArray,
  },
});

Điều này hoạt động bởi vì logic “mutating” được bao bọc bên trong phương thức produce của Immer khi thực thi.

Chú ý: logic “mutating” chỉ hoạt động đúng khi được bọc trong Immer! Nếu không, mã đó sẽ thực sự làm thay đổi dữ liệu.

Kết luận

Như vậy trong bài viết này, tôi nói đến 2 điểm chính:

  • Immutable trong tư tưởng Redux là state không thay đổi
  • Cập nhật state trong Redux Toolkit sử dụng Immer

Nguồn dịch

https://redux-toolkit.js.org/usage/immer-reducers#immuability-and-redux

Avatar photo

Clean Code: Nguyên tắc viết hàm trong lập trình…

Trong quá trình phát triển phần mềm, việc viết mã nguồn dễ đọc, dễ hiểu là yếu tố then chốt để đảm bảo code...
Avatar photo Dat Tran Thanh
3 min read

Clean Code: Nguyên tắc comment trong lập trình

Trong lập trình, code không chỉ là một tập hợp các câu lệnh để máy tính thực thi, mà còn là một hình thức...
Avatar photo Dat Tran Thanh
3 min read

Clean Code: Nguyên tắc xử lý lỗi (Error Handling)

Trong quá trình phát triển phần mềm, việc xử lý lỗi không chỉ là một phần quan trọng mà còn ảnh hưởng trực tiếp...
Avatar photo Dat Tran Thanh
4 min read

Leave a Reply

Your email address will not be published. Required fields are marked *