Skip to content

8.6 · Component + Recoil + MUI architecture

บทนี้อธิบายโครงสร้าง component ว่าแบ่งอย่างไร, state management ใช้ทั้ง class state + Recoil อย่างไร, และทำไม MUI v5 จึงประกาศไว้แต่แทบไม่ได้ใช้


ภาพรวม component layers

┌──────────────────────────────────────────────────┐
│  pages/  (871 ไฟล์) — หน้าเฉพาะฟีเจอร์           │
├──────────────────────────────────────────────────┤
│  components/  (104 ไฟล์, 41 โฟลเดอร์)             │
│  ├── shell: SiderLayout, Header, Breadcrumb       │
│  ├── form controls: FormContactUS, SettingInput   │
│  ├── display: AlldataShow, ModalshowAllImage      │
│  └── misc: ImageFileNewtab, Modal                 │
├──────────────────────────────────────────────────┤
│  AntD v4 components (default)                     │
│  Recoil (state)                                   │
│  MUI v5 (ใช้น้อยมาก)                              │
└──────────────────────────────────────────────────┘

2 สไตล์ component ผสมกัน

สไตล์ที่ 1: Class component (module เก่า)

พบใน Leave, Hospital, ManageUser, Document, Policy — module ที่สร้างก่อนปี 2022

js
// pages/ManageUser/index.js (ตัวอย่าง concept)
import { withApollo } from "react-apollo";
import { Form } from "antd";

class ManageUser extends React.Component {
  formRef = React.createRef();

  state = {
    users: [],
    loading: false,
    modalVisible: false,
  };

  componentDidMount() {
    this.fetchUsers();
  }

  fetchUsers = async () => {
    this.setState({ loading: true });
    const { data } = await this.props.client.query({
      query: Queries.FETCH_USERS,
      variables: { page: 1 },
    });
    this.setState({ users: data.fetchUsers, loading: false });
  };

  handleSubmit = async (values) => {
    await this.props.client.mutate({
      mutation: Mutations.CREATE_USER,
      variables: { input: values },
    });
    this.fetchUsers();
  };

  render() {
    return (
      <Form ref={this.formRef} onFinish={this.handleSubmit}>
        {/* ... */}
      </Form>
    );
  }
}

export default withApollo(ManageUser);

สไตล์ที่ 2: Function component + Recoil (module ใหม่)

พบใน WorkForce, PMS, Recruitment — module ที่สร้างปี 2022 ขึ้นไป

js
// pages/WorkForce/Employee/index.js (ตัวอย่าง concept)
import { useRecoilState, useSetRecoilState } from "recoil";
import { Form } from "antd";
import { useQuery, useMutation } from "@apollo/react-hooks";
import { employeeListState, currentEmployeeIdState } from "../../../recoil/states/WorkForce/Employee";
import { clientWf } from "../../../config/apollo-workforce";

const Employee = () => {
  const [form] = Form.useForm();
  const [employeeList, setEmployeeList] = useRecoilState(employeeListState);
  const setCurrentId = useSetRecoilState(currentEmployeeIdState);

  const { data, loading } = useQuery(Queries_wf.FETCH_EMPLOYEES, {
    client: clientWf,
    variables: { page: 1 },
  });

  const [createEmployee] = useMutation(Mutations_wf.CREATE_EMPLOYEE, {
    client: clientWf,
  });

  const handleSubmit = async (values) => {
    await createEmployee({ variables: { input: values } });
    form.resetFields();
  };

  return (
    <Form form={form} onFinish={handleSubmit}>
      {/* ... */}
    </Form>
  );
};

export default Employee;

ทำมี 2 สไตล์

  • ทีมเริ่มใช้ Recoil และ function component ตอนสร้าง WorkForce module (ปี 2022)
  • module เก่าไม่ได้ refactor ตาม — เพราะใช้งานอยู่และการ refactor มีความเสี่ยง
  • ผล: ผู้พัฒนาต้องรู้ทั้ง 2 สไตล์

ข้อแตกต่างสำคัญ — class vs function

ด้านClass (เก่า)Function + Recoil (ใหม่)
Statethis.state + this.setStateuseRecoilState
LifecyclecomponentDidMount, componentWillUnmountuseEffect
Form<Form ref={formRef}> + formRef.current<Form form={form}> + Form.useForm()
GraphQLthis.props.client.query()useQuery()
HOCwithApolloไม่ต้อง (ใช้ hook)
RefReact.createRef()useRef()
Error boundary(ไม่มี)(ไม่มี)

ข้อระวังเมื่อทำงานข้ามสไตล์

  • อย่า refactor class → function แบบสุ่ม — ต้องทำทีละ component และทดสอบ
  • state ไม่ sync ข้ามสไตล์ — class component ใช้ this.state, function component ใช้ Recoil ไม่ได้แชร์กัน
  • ถ้าต้อง sync ข้ามกัน — ต้องยก state ขึ้นไป parent หรือใช้ Recoil เป็นตัวกลาง

components/ — 41 โฟลเดอร์, 104 ไฟล์

หมวดหมู่ component

หมวดตัวอย่างจำนวน
Shell / layoutSiderLayout, Header, Breadcrumb, Layout5
Form controlsFormContactUS, SettingInput, FormInputHelper8
DisplayAlldataShow, ModalshowAllImage, ImageFileNewtab15
Modal / drawerModal, DrawerForm, ConfirmModal10
Misc utilityEventLog, ModalDownload, LoadingPage12

component ที่ใช้บ่อย

Componentที่ใช้หน้าที่
SiderLayoutshell ของทุกหน้าlayout + menu
AlldataShowหลายสิบไฟล์ตาราง + pagination
ModalshowAllImageหน้าที่มีรูปmodal แสดงภาพขนาดใหญ่
ImageFileNewtabหน้าที่มีเอกสารเปิดไฟล์ใน tab ใหม่
FormContactUSหน้า contactฟอร์มติดต่อ
SettingInputทุกฟอร์มตั้งค่า input rules

Recoil — state management

โครงสร้าง

src/recoil/
└── states/
    ├── WorkForce/
    │   ├── Employee/
    │   │   ├── index.js          — atom: employeeListState
    │   │   ├── currentId.js      — atom: currentEmployeeIdState
    │   │   ├── filters.js        — atom: employeeFiltersState
    │   │   ├── modal.js          — atom: employeeModalState
    │   │   └── viewMode.js       — atom: employeeViewModeState
    │   ├── PositionManage/
    │   ├── PositionTechnical/
    │   ├── PerfessionalLicense/      (typo — ห้ามแก้)
    │   ├── PerfessionalDepartment/  (typo — ห้ามแก้)
    │   ├── ManpowerManage/
    │   ├── Candidate/
    │   ├── JobRequest/
    │   ├── Learning/
    │   └── Report/
    ├── PMS/
    ├── Recruitment/
    └── ...

57 โฟลเดอร์, ~1,251 บรรทัดรวม

ตัวอย่าง atom

js
// src/recoil/states/WorkForce/Employee/index.js (ตัวอย่าง concept)
import { atom } from "recoil";

export const employeeListState = atom({
  key: "employeeListState",
  default: [],
});

export const employeeLoadingState = atom({
  key: "employeeLoadingState",
  default: false,
});

export const employeeFiltersState = atom({
  key: "employeeFiltersState",
  default: {
    search: "",
    departmentId: null,
    positionId: null,
    page: 1,
    limit: 20,
  },
});

วิธีใช้

js
import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil";
import {
  employeeListState,
  employeeFiltersState,
} from "../../../recoil/states/WorkForce/Employee";

const EmployeeList = () => {
  const [employees, setEmployees] = useRecoilState(employeeListState);
  const filters = useRecoilValue(employeeFiltersState);

  // อัปเดต state
  const handleAdd = (newEmp) => {
    setEmployees([...employees, newEmp]);
  };

  return (
    <Table dataSource={employees} />
  );
};

const EmployeeFilters = () => {
  const [filters, setFilters] = useRecoilState(employeeFiltersState);

  return (
    <Input
      value={filters.search}
      onChange={(e) => setFilters({ ...filters, search: e.target.value })}
    />
  );
};

ข้อดีของ Recoil

  • ไม่ต้อง prop drilling — component ที่อยู่ห่างกันสามารถอ่าน/เขียน state เดียวกันได้
  • ทำงานกับ React 16 เป็นธรรมชาติ (ไม่ต้องอัปเกรดเป็น React 18)
  • debug ง่าย — มี Recoil DevTools

ข้อเสีย

  • state ไม่ persist — refresh browser แล้ว state หาย (ต้องเก็บใน localStorage เอง)
  • ไม่ใช้กับ module เก่า — module เก่ายังใช้ this.state — ทำให้ state แยกกัน
  • เพิ่ม dependency — เป็น library เสริม

MUI v5 — ทำไมอยู่ใน package.json แต่ไม่ใช้

สถิติการใช้

  • @mui/*@5.11 อยู่ใน package.json
  • มีการ import @mui/* ใน 5 ไฟล์เท่านั้น
  • ไฟล์ที่ import ส่วนใหญ่ import แล้วไม่ได้ใช้จริง (เป็น import ค้างจากการทดลอง)

สาเหตุที่ประกาศไว้

  • ทีมวางแผนจะย้ายจาก AntD v4 ไป MUI v5 แต่ยังทำไม่เสร็จ
  • เริ่มใช้ใน component ใหม่ 2-3 ตัว แล้วหยุด

ผลกระทบ

  • bundle ใหญ่ขึ้น ~300 KB — ทั้งที่แทบไม่ได้ใช้
  • confusion — ผู้พัฒนาใหม่อาจคิดว่าจะใช้ MUI เป็นหลัก แล้วเขียน component ใหม่ด้วย MUI ทำให้เพิ่มความไม่ลงตัว

คำแนะนำ

  • ถ้าจะเขียน component ใหม่ — ใช้ AntD v4 (ตามกระแสหลักของแอป)
  • ถ้าจะ migrate ไป MUI — ต้องวางแผนเป็น project ใหญ่ ไม่ใช่ทีละ component
  • ถ้าจะลบ MUI ออก — ต้องลบจาก package.json + ลบ import ใน 5 ไฟล์

Form — 2 pattern

Pattern 1: <Form ref={formRef}> (class component)

js
class MyForm extends React.Component {
  formRef = React.createRef();

  handleSubmit = () => {
    this.formRef.current.validateFields().then((values) => {
      // submit
    });
  };

  setFields = (data) => {
    this.formRef.current.setFieldsValue(data);
  };

  render() {
    return (
      <Form ref={this.formRef}>
        <Form.Item name="name">
          <Input />
        </Form.Item>
        <Button onClick={this.handleSubmit}>Save</Button>
      </Form>
    );
  }
}

Pattern 2: Form.useForm() (function component)

js
const MyForm = () => {
  const [form] = Form.useForm();

  const handleSubmit = () => {
    form.validateFields().then((values) => {
      // submit
    });
  };

  const setFields = (data) => {
    form.setFieldsValue(data);
  };

  return (
    <Form form={form}>
      <Form.Item name="name">
        <Input />
      </Form.Item>
      <Button onClick={handleSubmit}>Save</Button>
    </Form>
  );
};

ข้อระวัง

  • อย่าผสม 2 pattern ใน component เดียว — เลือกอย่างเดียว
  • Form.useForm() ใช้ไม่ได้ใน class component — ต้องใช้ ref
  • Form.Item ที่อยู่ใน <Form> เดียวกัน share form instance — ระวังว่าชื่อ name จะซ้ำ

การจัดการ ref — 2 pattern

Pattern 1: React.createRef() (class)

js
formRef = React.createRef();
modalRef = React.createRef();

// ใช้
this.formRef.current.submit();
this.modalRef.current.open();

Pattern 2: useRef() (function)

js
const formRef = useRef();
const modalRef = useRef();

// ใช้
formRef.current.submit();
modalRef.current.open();

การจัดการ lifecycle — 2 pattern

Pattern 1: class lifecycle methods

js
class MyComponent extends React.Component {
  componentDidMount() {
    // หลัง mount
    this.fetchData();
  }

  componentDidUpdate(prevProps) {
    // หลัง update
    if (prevProps.id !== this.props.id) {
      this.fetchData();
    }
  }

  componentWillUnmount() {
    // ก่อน unmount
    this.cancelFetch();
  }
}

Pattern 2: useEffect

js
const MyComponent = ({ id }) => {
  useEffect(() => {
    fetchData();
    return () => cancelFetch();
  }, [id]); // dependency array
};

ข้อระวังเรื่อง useEffect

  • ถ้าลืม dependency array — effect จะ run ทุกครั้งที่ render (infinite loop risk)
  • ถ้าใส่ dependency ผิด — effect อาจไม่ run หรือ run บ่อยเกินไป
  • cleanup function ต้อง return เสมอ สำหรับ fetch/subscription — ไม่งั้น memory leak

Component ที่เป็นปัญหา — ไฟล์ใหญ่

Componentบรรทัดปัญหา
SiderLayout/index.js1,168shell + menu + breadcrumb + user info รวมกัน
pages/OtherWelfare/Form/index.js2,075ฟอร์มใหญ่สุด, multi-tab
pages/LeaveStudiesPayments/form/index.js1,824multi-tab + upload
pages/ManageUser/index.js762จัดการผู้ใช้ + role + permission
pages/Login/index.js418login + RSA + 2 ขั้นตอน

Pattern ที่ต้องระวัง

1. Mutation ใน render

js
// อย่าทำแบบนี้
render() {
  this.state.count = this.state.count + 1; // ❌
  return <div>{this.state.count}</div>;
}

// ทำแบบนี้
this.setState({ count: this.state.count + 1 }); // ✓ (แต่ระวัง loop)

2. Side effect ใน constructor

js
// อย่าทำ
constructor(props) {
  super(props);
  this.fetchData(); // ❌ — ทำใน componentDidMount
}

// ทำแบบนี้
componentDidMount() {
  this.fetchData(); // ✓
}

3. ลืม unmount subscription

js
componentDidMount() {
  this.subscription = someEvent.subscribe(this.handleEvent);
}

componentWillUnmount() {
  this.subscription.unsubscribe(); // ✓ — อย่าลืม
}

เปรียบเทียบกับ vtrc-web (ดู Volume 8 บท 6.6)

ด้านvtrc-rc-backofficevtrc-web
Component count104~70
Class componentใช้ (เก่า)ใช้ (เก่า)
Function componentใช้ (ใหม่)ใช้ (ใหม่)
State managementclass state + Recoilclass state (ไม่มี library)
MUIมีใน deps แต่ไม่ใช้ไม่มี
AntDv4v3
Pattern formForm.useForm + refref

เคล็ดลปตอนทำงานจริง

ตอนเพิ่ม component ใหม่

  1. ดู module ที่จะใส่ — เป็น module เก่า (class) หรือใหม่ (function + Recoil)
  2. ทำตาม pattern ของ module นั้น — อย่าผสม
  3. ถ้าเป็น module ใหม่ — ใช้ function component + Recoil (ถ้ามี state ร่วมกับ component อื่น) หรือ local state (ถ้าไม่ share)
  4. ตั้งชื่อไฟล์ตาม convention — <ComponentName>/index.js

ตอนเจอ component ที่ใช้ state เยอะ

  • พิจารณาย้าย state ไป Recoil ถ้ามี component อื่นต้องใช้
  • ถ้าเป็น local state — แยกเป็น custom hook เพื่อลดขนาด component

ตอนเจอ form ใหญ่

  • แยกเป็น sub-form แต่ละ section
  • ใช้ Form.useForm() แล้วส่ง form instance ไปยัง sub-component เป็น prop

ขั้นตอนถัดไป

ไป บท 7.7 Page deep dive — Login, ManageUser, LeaveStudiesPayments