Tôi đã tham gia vào nhiều project React-Redux trong nhiều năm qua. Ngay từ lần đầu tiên tiếp cận flux, tôi đã bị ấn tượng bởi sức mạnh đáng kinh ngạc trong việc xử lý các trường hợp phức tạp khi so với các mô hình quản lý luồng dữ liệu khác, là những mô hình có xu hướng trở nên rối rắm khi độ phức tạp của project tăng lên.

Khái niệm điều khiển luồng dữ liệu thì rất đơn giản và rõ ràng. Dữ liệu thay đổi sẽ được biểu diễn như 1 action với payload tối thiểu. Trạng thái của ứng dụng sẽ được xử lý với action tương ứng trong dãy action.

Khái niệm Redux có rất nhiều thuận lợi trên lý thuyết, tuy nhiên tôi không có ý định nói về chúng trong bài viết này. Chỉ có 1 bất lợi lớn duy nhất của mô hình immutable: chính là chi phí của nó. Tuy nhiên chi phí mà chúng ta phải trả cho việc handle dữ liệu immutable tính ra lại rất có lời khi xét đến việc tránh được render lại và định tuyến lại luồng dữ liệu của ứng dụng React. Ta cũng hoàn toàn theo dõi được sự khác biệt giữa 2 trạng thái kế tiếp, và đó là điều giúp tôi cân nhắc không đưa vào danh sách những điều bất lợi của Redux.

Động lực

Điểm bất lợi của Redux: nó quá dài dòng!

Giả sử rằng ta muốn tạo 1 async action, lấy danh sách các user và lưu chúng vào biến Redux store, ta sẽ cần 3 định nghĩa action:

const START_FETCHING_USERS = "START_FETCHING_USERS";  
const RESOLVE_FETCHING_USERS = "RESOLVE_FETCHING_USERS";  
const REJECT_FETCHING_USERS = "REJECT_FETCHING_USERS";  

Loại action đầu tiên - START_FETCHING_USERS sẽ bắt đầu quá trình, tiếp đó RESOLVE_FETCHING_USERS cung cấp các user và REJECT_FETCHING_USERS sẽ phát ra lỗi nếu có lỗi trong quá trình lấy dữ liệu.

Dưới đây là các hàm khởi tạo action:

const startFetchingUsers = () => ({ type: START_FETCHING_USERS });  
const resolveFetchingUsers = users => ({ type: RESOLVE_FETCHING_USERS, users });  
const rejectFetchingUsers = error => ({ type: RESOLVE_FETCHING_USERS, error });  

và reducer:

const initState = {  
 isFetching: false,
 users: [],
 error: null
}

const reducer = (state = initState, action) => {  
 switch (action.type) {
   case START_FETCHING_USERS: return {
     ...state,
     isFetching: true
   };
   case RESOLVE_FETCHING_USERS: return {
     ...state,
     isFetching: false,
     users: action.users
   };
   case REJECT_FETCHING_USERS: return {
     ...state,
     isFetching: false,
     error: action.error
   };
   default: return state;
 }
}

Còn lại thì sẽ là việc thực thi hàm khởi tạo action thunk bất đồng bộ:

const fetchUsers = () => async (dispatch, getState, { api }) => {  
 dispatch(startFetchingUsers());
 try {
   const users = await api.get('/users');
   dispatch(resolveFetchingUsers(users));
 } catch (error) {
   dispatch(rejectFetchingUsers(error.message));
 }
}

Đến đây là đã ổn cho phần Redux. Bây giờ ta cần kết hợp hàm khởi tạo action và state với các React component.

Như các bạn có thể thấy, chúng ta cần gõ khá là nhiều dòng code:

  • Type của action.
  • Hàm khởi tạo action.
  • Các hàm handle action tron reducer.

và chúng ta cũng chưa viết bất cứ view component nào cả :3

Điều này đặc biệt bất tiện trong việc develope các ứng dụng lớn với hàng triệu action type, hàm khởi tạo action, các reducer con. Càng khó khăn hơn khi những tài nguyên này bị xé lẻ trong nhiều file, trong nhiều thư mục khác nhau. Do đó nếu ta muốn theo dõi hiệu quả của 1 action, ta cần theo dấu luồng dữ liệu qua nhiều file, 1 công việc khá là khó khăn.

Khi tìm kiếm trên npm, chúng tôi tìm được kha khá các thư việc/middleware giúp chúng tôi tránh được việc phải gõ nhiều, tuy nhiên dùng chúng lại kéo theo việc phải import chúng vào từng file.

Có lẽ ta nên nghĩ đến 1 cách đơn giản hơn và cân nhắc xem tính năng nào thực sự cần thiết của Redux.

  1. Có cần giữ cho dữ liệu immutable không? Dữ liệu mutable hầu hết đều dẫn đến bế tắc. Do đó bỏ qua phương án này, đặc biệt càng không nên trong ứng dụng React.
  2. Có cần phải biết tên của từng action hay không? Trong phần lớn trường hợp, mỗi action được sử dụng trong 1 trường hợp cụ thể. Liệu có cách nào dispatch 1 action ẩn hay không? Nếu có thì hẳn rất tuyệt!
  3. Có cần serialize các action không? Có những trường hợp khi bạn hoàn toàn cần serialize các action, nhưng trong hầu hết các ứng dụng, bạn không cần làm việc đó. Do đó hãy tiếp tục giả sử rằng không có yêu cầu serialize nào.

Chúng ta nên biến đổi mô hình Redux để tạo ra các action gọn hơn. Chúng tôi muốn miêu tả mỗi action như 1 hàm đơn lẻ.

Tham khảo các khóa học lập trình online, onlab, và thực tập lập trình tại TechMaster

Repatch

Repatch bỏ qua phần action type và hàm khởi tạo action, giúp trả lời cho câu hỏi: "Liệu reducer có thể trở thành payload của action hay không?".

Dispatch Reducer

store.dispatch(state => ({ ...state, counter: state.counter + 1 }));  

Một action sẽ là 1 hàm trả về 1 reducer

const increment = amount => state => ({  
  ...state,
  counter: state.counter + amount
});

store.dispatch(increment(42));  

Repatch cũng có 1 class Store mà ta có thể minh họa với state mặc định:

import Store from 'repatch';

const store = new Store(initialState);  

Interface của Repatch khá tương đồng với interface của Redux, do đó chúng ta có thể sử dụng nó thư viện react-redux. Phương thức dispatch và subscribe cũng tương tự như Store Redux.

Middleware và Action bất đồng bộ

Repatch có interface cho chuỗi middleware. Đây là điểm thuận lợi cho việc sử dụng các middleware action bất đồng bộ. Package sẽ cung cấp 1 middleware thunk, tương tự như redux-think, rất tiện trong việc tạo các action bất đồng bộ. Nếu reducer trả về 1 hàm, nó sẽ tự động được middleware xem như 1 action bất đồng bộ. Hàm dispatch và getState sẽ được truyền vào như tham số bởi store. Bạn có thể thiết hình middeware để cung cấp thêm 1 tham số mở rộng. Có thể sử dụng nó trong việc chèn các thư viện API từ client.

Hãy xem ví dụ dưới:

const fetchUsers = () => _ => async (dispatch, getState, { api }) => {  
 dispatch(state => ({ ...state, isFetching: true }));
 try {
   const users = await api.get('/users');
   dispatch(state => ({ ...state, users }));
 } catch (error) {
   dispatch(state => ({ ...state, error: error.message }));
 } finally {
   dispatch(state => ({ ...state, isFetching: false }))
 }
}

Sức mạnh của Repatch được thể hiện rõ nhất khi sử dụng thunk middleware, giúp ta miêu tả các action bất đồng bộ trong chỉ vài dòng code. Như bạn có thể thấy, ta không cần định nghĩa dài dòng các type của action, các hàm khởi tạo action và các hàm handle action trong reducer nữa. Lúc này ta chỉ cần đơn giản dispatch 1 hàm arrow, dẫn đến 1 action ẩn sẽ được sinh ra. Rất tuyệt phải không? Theo cách này thì action cũng sẽ có thể được tạo ra trong component nữa.

const store = new Store({  
 isFetching: false,
 users: [],
 error: null
});

Ở đâu đó, store sẽ dispatch 1 action:

store.dispatch(fetchUsers()) 

Hãy xem 1 ví dụ khác:

const updateUser = delta => state => async (dispatch, getState, { api }) => {  
 try {
   const editedUserId = getState().editedUser;
   dispatch(toggleSpinner(true));
   await api.put(`/users/${editedUserId}`, { body: delta });
   await dispatch(fetchUsers());
   dispatch(toggleSpinner(false));
 } catch (error) {
   dispatch(state => ({ ...state, isFetching: false, error: error.message }));
 }
};

Trong ví dụ này, tham số mở rộng chính là đối tượng client API, chính là cái tôi đã đề cập ở trên. Cũng chú ý rằng, state của reducer không phải lúc nào cũng thỏa mãn việc đọc state do nó chỉ là biểu diễn tạm khi action được gọi. Do đó, ta cần sử dụng hàm getState thay vì state.

toggleSpinner là 1 action đồng bộ ta có thể dispatch. Phương thức api.put là 1 phương thức bất đồng bộ có nhiệm vụ gọi API. Dòng await dispatch(fetchUsers()) khá thú vị. Nhờ sử dụng redux-thunk, ta có thể nhúng các action bất đồng bộ vào trong các action bất đồng bộ khác.

Reducer con

Reducer con trong redux

Các reducer trong redux được sắp xếp theo 1 cấu trúc có thứ bậc. Vì lẽ đó, ta không cần thiết tạo 1 reducer lớn mà sẽ tạo ra các reducer nhỏ, lồng vào nhau. Kết hợp các reducer thì khá đơn giản, ta chỉ cần tạo 1 reducer giảm bớt từng phần thành object, sử dụng state con của chúng:

const rootReducer = (state, action) => ({  
 foo: fooReducer(state.foo, action),
 bar: barReducer(state.bar, action)
});

Đoạn code trên tương đương với:

const rootReducer = redux.combineReducers({  
  foo: fooReducer,
  bar: barReducer
});

Reducer con trong Repatch

Repatch cũng cung cấp 1 cách để kết hợp các reducer con. Ta chỉ cần định nghĩa 1 hàm nhận 1 nested reducer như 1 tham số, trả về 1 reducer với state được giảm bớt:

const reduceFoo = fooReducer => state => ({  
 ...state,
 foo: fooReducer(state.foo)
});

Bây giờ, giảm bớt thuộc tính foo khá đơn giản. Giả sử ta cần định nghĩa 1 thuộc tính x trong đối tượng foo:

const setX = x => reduceFoo(state => ({ ...state, x }));  

Điều này rất hữu dụng nếu các reducer con miêu tả các thuộc tính được lồng rất sâu:

const reduceFoo = reducer => state => ({  
  ...state,
  bar: {
    ...state.bar,
    foo: reducer(state.bar.foo)
  }
});

Test

Vấn đề test thì sao? Viết unit test cho 1 reducer khá đơn giản:

import * as assert from 'assert';  
import { changeName } from './actions';

// ...

it('changeName', () => {  
 const state = { name: 'john' };
 const nextState = changeName('jack')(state);
 assert.strictEqual(nextState.name, 'jack');
});

Với các action bất đồng bộ thì có phức tạp hơn 1 chút do chúng phụ thuộc vào các resource bên ngoài như store hay các API khác. Nhưng các resource bên ngoài nên được mock trong mọi môi trường.

import Store, { thunk } from 'repatch';  
import * as assert from 'assert';

const mockUsers = [{ username: 'john' }];  
const mockApi = {  
 getUsers: () => Promise.resolve(mockUsers)
}

// ...

it('fetchUsers', async () => {  
 const state = { users: [] };
 const store = new Store(state)
   .addMiddleware(thunk.withExtraArgument({ api: mockApi }));
 await store.dispatch(fetchUsers());
 const nextState = store.getState();
 assert.deepEqual(nextState.users, mockUsers);
});

Ứng dụng TODO

Mọi thư viện JavaScript đều có 1 ví dụ Todo, lẽ dĩ nhiên Repatch cũng có một. Nếu bạn muốn tìm ví dụ trong TypeScript, bạn nên xem ở đây.

Bài viết được dịch từ: https://community.risingstack.com/repatch-the-simplified-redux/