+3

[React] Đừng dùng React Toastify như một tay mơ - Kiến trúc Notification chuẩn Enterprise

anh em mình dân Backend, hệ thống, kiến trúc (architecture) lúc nào cũng đặt lên hàng đầu. Nhưng đôi khi "đá sân" sang Frontend hoặc review code của mấy bạn FE, nhìn cái cảnh gọi thư viện notification bừa phứa khắp các file là lại thấy ngứa mắt.

React Toastify là một thư viện quá phổ biến, dễ cài, dễ dùng. Nhưng chính vì nó DỄ, nên nhiều anh em dùng nó như một con "gà mờ", tạo ra một đống Technical Debt (nợ kỹ thuật) khi dự án phình to.

Hôm nay, anh em mình sẽ viết một bài "hạng nặng" về React Toastify. Không dạy cách cài đặt cơ bản (cái đó đọc docs là xong), mà sẽ hướng dẫn cách chuẩn hóa (normalize) và build kiến trúc xử lý notification cho một hệ thống Frontend quy mô lớn.

Lời mở đầu sự hỗi loạn của những chiếc Toast

Bất kỳ hệ thống nào từ E-commerce, Admin Dashboard cho đến các tool nội bộ, đều cần thông báo (Notification) để giao tiếp với người dùng. React Toastify thường là lựa chọn số 1.

Nhưng hãy nhìn lại source code của bạn xem, có phải bạn đang làm thế này không?

// File A.jsx
import { toast } from 'react-toastify';
toast.success('Thêm sản phẩm thành công!', { theme: 'dark', position: 'top-right' });

// File B.jsx
import { toast } from 'react-toastify';
toast.error('Lỗi máy chủ', { theme: 'light', position: 'bottom-center' });

Nỗi đau bắt đầu từ đây:

  1. Thiếu nhất quán: Chỗ thì theme dark, chỗ theme light, chỗ nằm góc phải, chỗ nằm giữa màn hình.
  2. Hardcode text khắp nơi: Khi sếp yêu cầu đổi toàn bộ thông báo lỗi màu đỏ sang màu cam, bạn phải Ctrl + Shift + F và sửa tay hàng trăm file.
  3. Spam màn hình: Khi mạng lag, 5 API đồng loạt throw error, người dùng ăn ngay 5 cái thông báo đỏ rực chồng lên nhau che kín cả màn hình.

Đã đến lúc chúng ta ngừng code như một cái máy và áp dụng tư duy "System Design" vào UI.

Cấp độ 1: Tạo Wrapper (Facade Pattern) - Gom về một mối

Đừng bao giờ import trực tiếp thư viện bên thứ 3 vào mọi ngóc ngách của dự án. Lỡ ngày mai React Toastify bị lỗi thời và bạn muốn chuyển sang react-hot-toast thì sao? Bạn sẽ phải sửa hàng trăm component?

Hãy tạo một file toastService.js (hoặc NotificationService.js). Tại đây, chúng ta sẽ định nghĩa lại toàn bộ "luật chơi".

// utils/toastService.js
import { toast } from 'react-toastify';

// Cấu hình mặc định chuẩn chỉ cho toàn hệ thống
const defaultOptions = {
    position: "top-right",
    autoClose: 3000,
    hideProgressBar: false,
    closeOnClick: true,
    pauseOnHover: true,
    draggable: true,
    theme: "colored", // Nhất quán 1 theme
};

export const ToastService = {
    success: (message, options = {}) => {
        toast.success(message, { ...defaultOptions, ...options });
    },
    error: (message, options = {}) => {
        toast.error(message, { ...defaultOptions, ...options });
    },
    info: (message, options = {}) => {
        toast.info(message, { ...defaultOptions, ...options });
    },
    warning: (message, options = {}) => {
        toast.warning(message, { ...defaultOptions, ...options });
    }
};

Bây giờ, ở các Component, bạn chỉ việc gọi:

import { ToastService } from '@/utils/toastService';
ToastService.success('Đã lưu dữ liệu!');

Code sạch sẽ, DRY (Don't Repeat Yourself) và cực kỳ dễ bảo trì.

Cấp độ 2: Xử lý SPAM với toastId (Idempotency trong UI)

Đây là bài toán rất hay gặp trong các hệ thống Dashboard phức tạp. Bạn load một trang tổng quan, nó gọi song song 4 API (Lấy user, lấy đơn hàng, lấy thống kê, lấy thông báo). Bỗng nhiên... rớt mạng. Cả 4 API đều văng lỗi "Network Error". React Toastify sẽ trung thực render ra 4 cái cục màu đỏ y hệt nhau. Trải nghiệm UX cực kỳ thảm họa!

Giải pháp hạng nặng: Sử dụng toastId. React Toastify cho phép bạn gắn một ID duy nhất cho mỗi thông báo. Nếu một thông báo với ID đó đang hiển thị, nó sẽ KHÔNG render thêm cái mới.

Hãy nâng cấp ToastService của chúng ta:

// utils/toastService.js
const NETWORK_ERROR_ID = 'network-error-toast';

export const ToastService = {
    // ... các hàm cũ
    
    networkError: (message = 'Lỗi kết nối mạng, vui lòng thử lại!') => {
        // Kiểm tra xem cái lỗi này đã hiển thị chưa
        if (!toast.isActive(NETWORK_ERROR_ID)) {
            toast.error(message, { 
                ...defaultOptions, 
                toastId: NETWORK_ERROR_ID // Gắn ID cố định
            });
        }
    }
};

Giờ thì dù có 10 API xịt cùng lúc, người dùng cũng chỉ thấy đúng 1 thông báo lỗi mạng một cách rất thanh lịch.

Cấp độ 3: Bắt tay với Axios Interceptors (Tự động hóa hoàn toàn)

Là một lập trình viên lười biếng (nhưng thông minh), tôi rất ghét việc cứ gọi API xong lại phải viết khối try/catch rồi thủ công gọi ToastService.error().

Tại sao không để Axios tự động làm việc đó ở tầng Network?

// api/axiosClient.js
import axios from 'axios';
import { ToastService } from '@/utils/toastService';

const axiosClient = axios.create({
    baseURL: process.env.NEXT_PUBLIC_API_URL,
});

axiosClient.interceptors.response.use(
    (response) => {
        // Tùy logic Backend, ví dụ API trả về HTTP 200 nhưng code = 0 là lỗi logic
        if (response.data?.code === 0) {
            ToastService.warning(response.data.message);
        }
        return response.data;
    },
    (error) => {
        // Xử lý Global Error
        const status = error.response?.status;
        
        if (status === 401) {
            ToastService.error('Phiên đăng nhập hết hạn!');
            // Redirect to login...
        } else if (status === 403) {
            ToastService.error('Bạn không có quyền thực hiện hành động này!');
        } else if (status === 500) {
            ToastService.error('Lỗi hệ thống nghiêm trọng, vui lòng báo kỹ thuật!');
        } else if (error.message === 'Network Error') {
            ToastService.networkError(); // Gọi hàm chống Spam ở trên
        } else {
            // Lấy message từ backend trả về nếu có
            const errorMessage = error.response?.data?.message || 'Có lỗi xảy ra!';
            ToastService.error(errorMessage);
        }

        return Promise.reject(error);
    }
);

export default axiosClient;

Thành quả: Từ nay, ở Component bạn cứ vô tư gọi API. Lỗi 401, 403, 500 hay mất mạng... hệ thống tự động giăng Toast ra. Bạn chỉ cần tập trung xử lý Happy Path (luồng thành công) ở Component. Tách biệt hoàn toàn Business Logic và UI Notification!

Cấp độ 4: Interactive Toast - Không chỉ là thông báo (Advanced)

React Toastify không chỉ nhận String, nó nhận vào một React Node. Tức là bạn có thể nhét cả một Component có nút bấm, có logic vào trong đó.

Ví dụ: Tính năng xóa sản phẩm và cho phép Undo (Hoàn tác).

const UndoToast = ({ productName, onUndo, closeToast }) => (
    <div>
        <p>Đã xóa <strong>{productName}</strong></p>
        <button 
            onClick={() => {
                onUndo();
                closeToast(); // Nút đóng mặc định được inject vào
            }}
            style={{ padding: '5px 10px', background: '#333', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', marginTop: '10px'}}
        >
            Hoàn tác (Undo)
        </button>
    </div>
);

// Cách gọi:
toast.info(
    <UndoToast 
        productName="Bàn phím cơ" 
        onUndo={() => { console.log('Gọi API khôi phục sản phẩm...'); }} 
    />, 
    { autoClose: 5000 }
);

Bạn vừa biến một cái thông báo khô khan thành một tính năng tương tác xịn xò như Gmail!

Tóm lại

Dùng thư viện thì dễ, dùng sao cho hệ thống dễ bảo trì, có tính mở rộng cao và UX tốt mới là điều phân biệt giữa Junior và Senior.

  1. Luôn dùng Wrapper Pattern để quản lý config tập trung.
  2. Dùng toastId để chống spam thông báo rác rưởi.
  3. Tích hợp thẳng vào HTTP Interceptor để tự động hóa việc bắt lỗi.
  4. Tận dụng khả năng render React Component để làm các tính năng tương tác sâu (như Undo).

Anh em thường xử lý Notification trong dự án của mình như thế nào? Có bao giờ bị dính cái bẫy "Hardcode muôn nơi" chưa? Để lại bình luận anh em cùng đàm đạo nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí