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 (ใหม่) |
|---|---|---|
| State | this.state + this.setState | useRecoilState |
| Lifecycle | componentDidMount, componentWillUnmount | useEffect |
| Form | <Form ref={formRef}> + formRef.current | <Form form={form}> + Form.useForm() |
| GraphQL | this.props.client.query() | useQuery() |
| HOC | withApollo | ไม่ต้อง (ใช้ hook) |
| Ref | React.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 / layout | SiderLayout, Header, Breadcrumb, Layout | 5 |
| Form controls | FormContactUS, SettingInput, FormInputHelper | 8 |
| Display | AlldataShow, ModalshowAllImage, ImageFileNewtab | 15 |
| Modal / drawer | Modal, DrawerForm, ConfirmModal | 10 |
| Misc utility | EventLog, ModalDownload, LoadingPage | 12 |
component ที่ใช้บ่อย
| Component | ที่ใช้ | หน้าที่ |
|---|---|---|
SiderLayout | shell ของทุกหน้า | 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 — ต้องใช้refForm.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.js | 1,168 | shell + menu + breadcrumb + user info รวมกัน |
pages/OtherWelfare/Form/index.js | 2,075 | ฟอร์มใหญ่สุด, multi-tab |
pages/LeaveStudiesPayments/form/index.js | 1,824 | multi-tab + upload |
pages/ManageUser/index.js | 762 | จัดการผู้ใช้ + role + permission |
pages/Login/index.js | 418 | login + 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-backoffice | vtrc-web |
|---|---|---|
| Component count | 104 | ~70 |
| Class component | ใช้ (เก่า) | ใช้ (เก่า) |
| Function component | ใช้ (ใหม่) | ใช้ (ใหม่) |
| State management | class state + Recoil | class state (ไม่มี library) |
| MUI | มีใน deps แต่ไม่ใช้ | ไม่มี |
| AntD | v4 | v3 |
| Pattern form | Form.useForm + ref | ref |
เคล็ดลปตอนทำงานจริง
ตอนเพิ่ม component ใหม่
- ดู module ที่จะใส่ — เป็น module เก่า (class) หรือใหม่ (function + Recoil)
- ทำตาม pattern ของ module นั้น — อย่าผสม
- ถ้าเป็น module ใหม่ — ใช้ function component + Recoil (ถ้ามี state ร่วมกับ component อื่น) หรือ local state (ถ้าไม่ share)
- ตั้งชื่อไฟล์ตาม convention —
<ComponentName>/index.js
ตอนเจอ component ที่ใช้ state เยอะ
- พิจารณาย้าย state ไป Recoil ถ้ามี component อื่นต้องใช้
- ถ้าเป็น local state — แยกเป็น custom hook เพื่อลดขนาด component
ตอนเจอ form ใหญ่
- แยกเป็น sub-form แต่ละ section
- ใช้
Form.useForm()แล้วส่งforminstance ไปยัง sub-component เป็น prop
ขั้นตอนถัดไป
ไป บท 7.7 Page deep dive — Login, ManageUser, LeaveStudiesPayments →