Series cải tiến Ant Design – Đóng gói logic đóng mở cho `Modal` và tối ưu hiệu suất render

Tổng quan

Ant Design là thư viện UI khá phổ biến trong cộng đồng React. Ant Design cung cấp một design system với những component toàn diện và đầy đủ tính năng. Nhờ đó các nhà phát triển sản phẩm có thể tập trung vào business nhiều hơn mà không mất nhiều thời gian vào build-up design system từ đầu.

Trong những dự án cần ưu tiên tốc độ phát triển nghiệp vụ (shipping features) thì Ant Design rất tuyệt vời. Nhưng khi cần customize để đáp ứng nghiệp vụ, bạn sẽ nhận ra việc customize không hề dễ dàng và performance của Ant Design chỉ ở mức trung bình. Ví dụ như bạn cần validate form với những điều kiện phức tạp, customize Table để edit được trong bảng, …

Series này là một số kinh nghiệm mà tôi đúc kết qua nhiều dự án dùng Ant Design. Hy vọng nhận được sự ủng hộ và góp ý từ mọi người ạ!

Cải tiến Modal

Vấn đề

Modal là component dùng để hiển thị một thành phần nổi trên giao diện khi cần tập trung hiển thị một nội dung nào đó hoặc yêu cầu tương tác của người dùng nhưng vẫn giữ bối cảnh hiện tại của giao diện.

alt text

Điều khiển logic đóng/mở modal bằng cách tạo một state (ví dụ isModalOpen) và truyền nó vào prop open của Modal. Cách sử dụng rất đơn giản! Nhưng tôi nhận thấy nó bộc lộ 2 điểm yếu:

  • State đặt ở component cha, cho nên dù chỉ thao tác đóng/mở đơn giản nhưng cũng khiến component cha phải render lại.
  • Ứng với mỗi modal là có một state. Nếu component cha chứa nhiều modal, nó cũng cần nhiều state tương ứng. Tôi muốn component cha trông gọn gàng, giảm bớt những tiểu tiết hơn.

Qua đôi lần sử dụng thư viện Shadcn UI, tôi thấy cách thiết kế component của Shadcn rất hay. Những logic đơn giản mà lặp lại nhiều lần, chúng ta có thể đóng gói lại trong một component wrapper và dùng React Context để chia sẻ trạng thái. Hiệu quả mang lại rất tuyệt vời. Chỉ cần bọc wrapper, các component bên trong sẽ ăn theo logic đó, giảm bớt những props tiểu tiết, giảm phạm vi re-render.

Giải pháp

Lời giải cơ bản

Tạo context và provider:

interface IDialogContext {
  isOpen: boolean;
  open: () => void;
  close: () => void;
}

const defaultValue: IDialogContext = {
  isOpen: false,
  open: () => null,
  close: () => null,
};

const DialogContext = createContext<IDialogContext>(defaultValue);

const Dialog: React.FC<{
  children: React.ReactNode;
  open?: boolean;
  onOpenChange?: (value: boolean) => void;
}> = ({ children, ...props }) => {
  const [isOpen, setIsOpen] = useState(defaultValue.isOpen);

  useEffect(() => {
    if (props.open !== undefined) {
      setIsOpen(props.open);
    }
  }, [props.open]);

  const handleOpen = () => {
    setIsOpen(true);
  };

  const handleClose = () => {
    setIsOpen(false);
  };

  return (
    <DialogContext value={{ isOpen, open: handleOpen, close: handleClose }}>
      {children}
    </DialogContext>
  );
};

Tạo custom hook để truy cập vào context của Dialog:

const useDialog = () => {
  const context = useContext(DialogContext);

  if (!context) {
    throw new Error("useDialog hook must be used within Dialog component.");
  }

  return context;
};

Tạo component để trigger đóng/mở dialog:

const DialogTrigger: React.FC<
  React.ComponentProps<typeof Button> & { asChild?: boolean }
> = ({ asChild, children, onClick, ...props }) => {
  const Comp = asChild ? Slot : Button;
  const { open } = useDialog();

  return (
    <Comp
      onClick={(e) => {
        onClick?.(e as React.MouseEvent<HTMLButtonElement>);
        open();
      }}
      {...props}
    >
      {children}
    </Comp>
  );
};

Slot là một pattern rất hữu dụng cho React, có tác dụng merge các props vào child component đầu tiên của nó. Trong mẫu code trên, khi asChild của DialogTrigger bằng true, hàm onClick của Comp sẽ được merge vào hàm onClick của child component. Nếu child component là một Button, nó sẽ thừa hưởng luôn logic xử lý sự kiện của DialogTrigger.

<DialogTrigger asChild>
  <Button>Open dialog</Button>
</DialogTrigger>

Bạn có thể cài package @radix-ui/react-slot hoặc nhờ AI gen ra component Slot theo pattern tương tự như thư viện kia.

Tạo component để custom component Modal sẵn có của Ant Design:

const DialogContent: React.FC<React.ComponentProps<typeof Modal>> = ({
  children,
  onCancel,
  ...props
}) => {
  const { isOpen, close } = useDialog();

  return (
    <Modal
      open={isOpen}
      onCancel={(e) => {
        onCancel?.(e);
        close();
      }}
      footer={null}
      destroyOnHidden
      {...props}
    >
      {children}
    </Modal>
  );
};

Sau đó bạn có thể sử dụng mẫu code sau để thực hiện đóng/mở dialog:

<Dialog>
  <DialogTrigger asChild>
    <Button>Create</Button>
  </DialogTrigger>
  <DialogContent title={"Create a post"}>
    <CreatePostForm />
  </DialogContent>
</Dialog>

Giả dụ CreatePostForm là một form, sau khi ấn nút “OK” thì call api rồi đóng dialog. Bạn có thể dùng hook useDialog để lấy và gọi hàm close để đóng dialog.

Nâng cao

Tuy nhiên có nhiều tình huống không thuần túy chỉ có đóng/mở dialog. Ví dụ khi tôi bấm vào một bản ghi trong table rồi mở ra dialog chi tiết của bản ghi đó, tôi cần truyền thông tin về bản ghi mà tôi bấm, ít nhất là id của bản ghi đó. Vậy có cách nào để truyền thông tin xuyên qua dialog nhưng vẫn tránh re-render component cha không?

Tôi sẽ thêm ctx state vào context và set giá trị cho nó thông qua parameter của hàm open, đồng thời expose hàm open ra ngoài component Dialog để có thể gọi được hàm bên ngoài context.

Sau đây là mẫu code đầy đủ:

import { Modal, Button } from "antd";
import React, {
  createContext,
  forwardRef,
  useContext,
  useEffect,
  useImperativeHandle,
  useState,
} from "react";
import { Slot } from "./slot";

interface IDialogContext {
  isOpen: boolean;
  open: (ctx?: any) => void;
  close: () => void;
  ctx?: any;
}

const defaultValue: IDialogContext = {
  isOpen: false,
  open: () => null,
  close: () => null,
};

const DialogContext = createContext<IDialogContext>(defaultValue);

interface DialogImperativeHandle<T = any> {
  open: (ctx?: T) => void;
  close: () => void;
}

const Dialog = forwardRef<
  DialogImperativeHandle,
  {
    children: React.ReactNode;
    open?: boolean;
    onOpenChange?: (value: boolean) => void;
  }
>(({ children, ...props }, ref) => {
  const [isOpen, setIsOpen] = useState(defaultValue.isOpen);
  const [ctx, setCtx] = useState();

  useEffect(() => {
    if (props.open !== undefined) {
      setIsOpen(props.open);
    }
  }, [props.open]);

  const handleOpen = (ctx?: any) => {
    setIsOpen(true);
    setCtx(ctx);
  };

  const handleClose = () => {
    setIsOpen(false);
    setCtx(undefined);
  };

  useImperativeHandle(ref, () => ({
    open: handleOpen,
    close: handleClose,
  }));

  return (
    <DialogContext
      value={{ isOpen, open: handleOpen, close: handleClose, ctx }}
    >
      {children}
    </DialogContext>
  );
});

Dialog.displayName = "Dialog";

const useDialog = () => {
  const context = useContext(DialogContext);

  if (!context) {
    throw new Error("useDialog hook must be used within Dialog component.");
  }

  return context;
};

const DialogContent: React.FC<React.ComponentProps<typeof Modal>> = ({
  children,
  onCancel,
  ...props
}) => {
  const { isOpen, close } = useDialog();

  return (
    <Modal
      open={isOpen}
      onCancel={(e) => {
        onCancel?.(e);
        close();
      }}
      footer={null}
      destroyOnHidden
      {...props}
    >
      {children}
    </Modal>
  );
};

DialogContent.displayName = "DialogContent";

const DialogTrigger: React.FC<
  React.ComponentProps<typeof Button> & { asChild?: boolean }
> = ({ asChild, children, onClick, ...props }) => {
  const Comp = asChild ? Slot : Button;
  const { open } = useDialog();

  return (
    <Comp
      onClick={(e) => {
        onClick?.(e as React.MouseEvent<HTMLButtonElement>);
        open();
      }}
      {...props}
    >
      {children}
    </Comp>
  );
};

DialogTrigger.displayName = "DialogTrigger";

const DialogClose: React.FC<
  React.ComponentProps<typeof Button> & { asChild?: boolean }
> = ({ asChild, children, onClick, ...props }) => {
  const Comp = asChild ? Slot : Button;
  const { close } = useDialog();

  return (
    <Comp
      onClick={(e) => {
        onClick?.(e as React.MouseEvent<HTMLButtonElement>);
        close();
      }}
      {...props}
    >
      {children}
    </Comp>
  );
};

DialogClose.displayName = "DialogClose";

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  useDialog,
  type DialogImperativeHandle,
};

Áp dụng:

import { Button, Form, Input } from "antd";
import {
  Dialog,
  DialogContent,
  DialogTrigger,
  useDialog,
  type DialogImperativeHandle,
} from "./components/dialog";

const createPost = (data: any) =>
  new Promise((resolve) => setTimeout(() => resolve(data), 1000));

const PostList: React.FC = () => {
  const openUpdatePostDialogRef = useRef<DialogImperativeHandle>(null);    const handleOpenUpdatePostDialog = () => {
    openDialogRef.current?.open({
      id: 1,       title: "Title",
      content: "This is the content.",
    });
  }

  return (
     <div className="flex justify-center gap-4">
       <Button onClick={handleOpenUpdatePostDialog}>
          Edit
       </Button>
       <Dialog ref={openDialogRef}>
         <DialogContent title={"Update post"}>
           <CreateUpdatePostForm />
         </DialogContent>
       </Dialog>

       <Dialog>
         <DialogTrigger asChild>
           <Button>Create</Button>
         </DialogTrigger>
         <DialogContent title={"Create a post"}>
           <CreateUpdatePostForm />
         </DialogContent>
       </Dialog>
     </div>
  )
}

const CreateUpdatePostForm: React.FC = () => {
  const [form] = Form.useForm();
  const { close, ctx } = useDialog();
  const [loading, startTransition] = useTransition();

  useEffect(() => {
    if (ctx) {
      form.setFieldsValue({
        title: ctx.title,
        content: ctx.content,
      });
    }
  }, [ctx, form]);

  const handleSubmit = (values: any) => {
    startTransition(async () => {
      await createPost(values).then(() => close());
    });
  };

  return (
    <Form form={form} layout="vertical" onFinish={handleSubmit}>
      <Form.Item name={"title"} label={"Title"} rules={[{ required: true }]}>
        <Input />
      </Form.Item>
      <Form.Item name={"content"} label={"Content"}>
        <Input.TextArea />
      </Form.Item>
      <div className="flex justify-center gap-4">
        <Button onClick={close}>Cancel</Button>
        <Button type="primary" htmlType="submit" loading={loading}>
          OK
        </Button>
      </div>
    </Form>
  );
};

 

alt text

Link demo (xin lỗi vì không thể nhúng frame vào bài viết được)

Top bài viết trong tháng

Lên đầu trang

FORM ỨNG TUYỂN

Click or drag a file to this area to upload.
File đính kèm định dạng .docs/.pdf/ và nhỏ hơn 5MB