4.2.2 · GraphQL catalog + Component patterns + Recoil
บทนี้รวม 3 หัวข้อที่ V1 แยกเป็น 2 บท (graphql-catalog + component-recoil-mui) — ทั้งหมดเกี่ยวกับวิธีที่หน้าจอต่อกับ backend (GraphQL), วิธีจัด component, และวิธีจัดการ state (Recoil + class state mix)
Part 1 · GraphQL catalog
โครงสร้างไฟล์
vtrc-rc-backoffice/src/graphql/:
| ไฟล์ | บรรทัด | gql operations | Export count |
|---|---|---|---|
queries.js | 4,108 | 121 | 121 |
mutations.js | 2,208 | 94 | 94 |
WorkForce/queries.js | ~1,500 | ~40 | ~40 |
WorkForce/mutations.js | ~900 | ~30 | ~30 |
index.js | 5 | — | — |
หมายเหตุ: V1 ระบุ 120 queries แต่การนับใหม่ด้วย rg "^const \w+ = gql\" queries.js | wc -l` ได้ 121 — V1 ผิดเล็กน้อย
รูปแบบนิยาม
vtrc-rc-backoffice/src/graphql/queries.js (pattern):
import gql from "graphql-tag";
const FETCH_USERS = gql`
query FetchUsers($page: Int, $limit: Int, $search: String) {
fetchUsers(page: $page, limit: $limit, search: $search) {
id
username
firstName
lastName
email
role { id name }
}
}
`;
// ไฟล์ปิดท้ายด้วย export default { ... } ไม่ใช่ export const
export default {
FETCH_USERS,
// ... 120 queries อื่น ๆ
};ข้อระวังเรื่องรูปแบบ
- ใช้
export default { ... }ไม่ใช่export const— grep หา^export constไม่เจอ ต้อง grep^const \w+ = gql\`` หรืออ่านexport default` block ท้ายไฟล์ - ชื่อ query ในไฟล์เป็น
UPPER_SNAKE_CASE(FETCH_USERS,FETCH_NEWS) แต่ operation name ในgqlblock เป็นPascalCase(FetchUsers) — pattern นี้ทำให้แยก component consumer (Queries.FETCH_USERS) กับ GraphQL wire ได้ชัด - มี snapshot หลายตัว —
queries_bk.js,queries_bk-061265.js,mutations_bk.js,queries_bk-prd.js— ห้ามใช้เป็นต้นแบบ เพราะอาจมี field deprecated
วิธี import ใน component
แบบที่ 1: import ทั้ง object (พบบ่อยในไฟล์เก่า / class component):
import Queries from "../graphql/queries";
import Mutations from "../graphql/mutations";
// ใช้
const { data } = await this.props.client.query({
query: Queries.FETCH_USERS,
variables: { page: 1, limit: 20 }
});แบบที่ 2: import ผ่าน hub index.js (พบในไฟล์ใหม่ / function component):
import { Queries, Mutations } from "../../graphql";
// ใช้ผ่าน useQuery hook
const { data, loading, error } = useQuery(Queries.FETCH_USERS, {
variables: { page: 1, limit: 20 }
});กลุ่มฟีเจอร์หลัก (10 กลุ่ม)
| กลุ่ม | ตัวอย่าง operation | จำนวนโดยประมาณ |
|---|---|---|
| Auth/user/role | LOGIN_BACKOFFICE, REFRESH_TOKEN_BO, FETCH_USERS, CREATE_USER, UPDATE_USER_ROLE | 25 |
| Organization/career/course | FETCH_ORGANIZATIONS, FETCH_CAREERS, FETCH_COURSES | 20 |
| Document/policy | FETCH_POLICIES, CREATE_POLICY, FETCH_POLICY_CATEGORIES | 15 |
| Hospital/medical claim | FETCH_HOSPITAL_HR, FETCH_HOSPITAL_PAID, FETCH_HOSPITAL_FAILED | 18 |
| Leave | FETCH_LEAVE_STUDIES_PAYMENTS, FETCH_LIST_STUDY_LEAVE_REQUEST, APPROVE_LEAVE | 15 |
| Welfare/fund | FETCH_WELFARE, FETCH_FUND, UPDATE_DELEGATES_EXPIRE | 10 |
| News/notification | FETCH_NEWS, CREATE_NEWS, SEND_NOTIFICATION_BLAST | 10 |
| System/dashboard | FETCH_DASHBOARD_STATS, FETCH_LOGS | 5 |
| Recruitment | FETCH_RECRUITMENT_APPLICANTS, UPDATE_RECRUITMENT_STATUS | 8 |
| WorkForce module (แยก sub-folder) | FETCH_EMPLOYEES, FETCH_POSITIONS_MANAGE, FETCH_PROFESSIONAL_LICENSES, FETCH_MANPOWER_MANAGE, FETCH_LEARNING | 40 |
Typo ที่ต้อง maintain
refundScholaship(ควรเป็นrefundScholarship) — V1 ระบุไว้แล้ว ยืนยันจากการ grep โค้ดปัจจุบัน — query name จริงในไฟล์- WorkForce path:
Perfessional(ควรเป็นProfessional) — path จริงในgraphql/WorkForce/แต่ query name สะกดถูก (FETCH_PROFESSIONAL_LICENSES) TheamCsspage — typo ของThemeCssแต่ route และ import อ้างชื่อนี้
ปัญหาที่ต้องระวังเมื่อแก้ query/mutation
- field ไม่ minimal — query อย่าง
fetchLeaveStudiesPaymentsดึง relation ลึก (user.position.organization,attachments,paymentHistoryฯล啡) ในก้อนเดียว ทำให้ response ใหญ่และ resolver ฝั่ง server หนัก OtherWelfareInputมี field 50+ — validation ฝั่ง server หนัก และ error 1 field ทำให้ mutation ทั้งก้อนล้มเหลว- N+1 pattern — บาง list component ดึง relation (เช่น
approver) แยก query ต่อแถวแทนฝัง relation ในผลลัพธ์หลัก - ห้ามลบ field โดยไม่ grep หา consumer ก่อน — เหมือน
vtrc-webเพราะไม่มี TypeScript ตรวจ compile-time - ไม่มี fragment ใช้เลย — pattern เดียวกับ
vtrc-webถ้าจะเริ่มใช้ต้องคิดเป็น convention ใหม่ทั้งไฟล์ - WorkForce module query แยก sub-folder —
WorkForce/queries.jsไม่ได้ merge เข้าqueries.jsหลัก ต้อง import 2 ที่ถ้าใช้ query จากทั้งคู่
Part 2 · Component architecture — 2 สไตล์ผสมกัน
pages/ 871 ไฟล์ — หน้าเฉพาะฟีเจอร์
components/ 104 ไฟล์ (40 โฟลเดอร์) — shell (SiderLayout, Header, Breadcrumb),
form controls (FormContactUS, SettingInput), display (AlldataShow,
ModalshowAllImage, ImageFileNewtab), modal/drawer, misc utilityสไตล์ 1 — Class component (module เก่า: Leave, Hospital, ManageUser, Document, Policy)
this.state/this.setState, componentDidMount/componentWillUnmount, <Form ref={formRef}> + formRef.current, this.props.client.query()/.mutate() ผ่าน withApollo HOC, React.createRef()
ตัวอย่าง:
import React, { Component } from 'react'
import { Form, Input, Button } from 'antd'
import { withApollo } from 'react-apollo'
import Queries from '../../graphql/queries'
class ManageUserForm extends Component {
state = { users: [], loading: false }
formRef = React.createRef()
componentDidMount() {
this.fetchUsers()
}
fetchUsers = async () => {
this.setState({ loading: true })
const { data } = await this.props.client.query({
query: Queries.FETCH_USERS,
variables: { page: 1, limit: 20 }
})
this.setState({ users: data.fetchUsers, loading: false })
}
handleSubmit = () => {
this.formRef.current.validateFields(async (err, values) => {
if (!err) {
await this.props.client.mutate({
mutation: Mutations.CREATE_USER,
variables: values
})
}
})
}
render() {
return (
<Form ref={this.formRef} onSubmit={this.handleSubmit}>
{/* form fields */}
</Form>
)
}
}
export default withApollo(ManageUserForm)สไตล์ 2 — Function component + Recoil (module ใหม่: WorkForce, PMS, Recruitment — เริ่มปี 2022)
useState/useRecoilState, useEffect, <Form form={form}> + Form.useForm(), useQuery/useMutation (ไม่ต้อง withApollo), useRef()
ตัวอย่าง:
import React from 'react'
import { Form, Input, Button } from 'antd'
import { useQuery, useMutation } from '@apollo/react-hooks'
import { useRecoilState } from 'recoil'
import { Queries, Mutations } from '../../graphql'
import { currentUserAtom } from './recoil/atoms'
const WorkForceEmployeeList = () => {
const [currentUser, setCurrentUser] = useRecoilState(currentUserAtom)
const [form] = Form.useForm()
const { data, loading, error } = useQuery(Queries.FETCH_EMPLOYEES, {
variables: { limit: 50 }
})
const [createEmployee] = useMutation(Mutations.CREATE_EMPLOYEE)
useEffect(() => {
if (data) {
// sync to atom if needed
}
}, [data])
const handleSubmit = async () => {
const values = await form.validateFields()
await createEmployee({ variables: values })
}
return (
<Form form={form} onFinish={handleSubmit}>
{/* form fields */}
</Form>
)
}
export default WorkForceEmployeeListทำไมมี 2 สไตล์
ทีมเริ่มใช้ Recoil + function component ตอนสร้าง WorkForce module ในปี 2022 — เป็นโอกาสทดลอง pattern ใหม่ แต่ module เก่า (Leave, Hospital, ManageUser, Document, Policy) ไม่ได้ refactor ตามเพราะ:
- ใช้งานจริงอยู่ — refactor มีความเสี่ยงสูง
- Testing ไม่พอ — ไม่มี test suite ครอบคลุม ทำให้ regression หลุดได้
- Time pressure — ทีมต้องการ ship ฟีเจอร์ใหม่มากกว่า refactor ของเก่า
ผลคือผู้พัฒนาต้องรู้ทั้ง 2 สไตล์ และ state ไม่ sync ข้ามสไตล์ — class component ใช้ this.state, function component ใช้ Recoil ไม่แชร์กัน ถ้าต้อง sync ต้องยกขึ้น parent หรือผ่าน Recoil atom เป็นตัวกลาง
กฎเมื่อทำงาน
- ทำตาม pattern ของ module ที่กำลังแก้ — ถ้า module เป็น class ให้เขียน class ต่อ ถ้าเป็น function+Recoil ให้เขียน function ต่อ
- อย่าผสม 2 pattern ใน component เดียว — เช่น
Form.useForm()ใช้ไม่ได้ใน class component ต้องใช้ref - อย่า refactor class → function แบบสุ่ม — ต้องทำทีละ component พร้อมทดสอบ
- ถ้าเริ่ม module ใหม่ → ใช้ function + Recoil (pattern ใหม่กว่า)
Part 3 · Recoil atoms — 212 atoms across 57 folders
vtrc-rc-backoffice/src/pages/WorkForce/recoil/ มี atom definition กระจายอยู่ 57 folder รวมเป็น 212 atoms — จำนวนมากเพราะแต่ละ field ของ form มักมี atom ของตัวเอง
ตัวอย่าง atom definition
// src/pages/WorkForce/recoil/employeeAtom.js
import { atom } from 'recoil'
export const employeeIdAtom = atom({
key: 'employeeId',
default: null
})
export const employeeFirstNameAtom = atom({
key: 'employeeFirstName',
default: ''
})
export const employeeLastNameAtom = atom({
key: 'employeeLastName',
default: ''
})
// ... ~200 atoms อื่น ๆกลุ่ม atom หลัก
| กลุ่ม | ตัวอย่าง atom | จำนวน |
|---|---|---|
| Employee master | employeeIdAtom, employeeFirstNameAtom, employeeLastNameAtom, employeePositionAtom | 60 |
| Position manage | positionIdAtom, positionNameAtom, positionLevelAtom | 25 |
| Professional license | licenseIdAtom, licenseTypeAtom, licenseExpiryAtom | 20 |
| Manpower | manpowerIdAtom, manpowerYearAtom, manpowerCountAtom | 15 |
| Learning | learningIdAtom, learningNameAtom, learningDateAtom | 12 |
| Form state | currentStepAtom, isSubmittingAtom, formErrorAtom | 10 |
| UI state | isModalVisibleAtom, selectedTabAtom | 8 |
| Filter/search | searchKeywordAtom, filterStatusAtom | 7 |
| Master data | careerOptionsAtom, organizationOptionsAtom | 5 |
| Misc | (กระจาย) | 50 |
Pattern ที่ใช้บ่อย
Read + Write (useRecoilState):
const [employee, setEmployee] = useRecoilState(employeeAtom)
// อ่าน: employee
// เขียน: setEmployee({ ...employee, firstName: 'new' })Read only (useRecoilValue):
const employee = useRecoilValue(employeeAtom)
// อ่านอย่างเดียว — ไม่สามารถ setWrite only (useSetRecoilState):
const setEmployee = useSetRecoilState(employeeAtom)
// เขียนอย่างเดียว — ประหยัด re-renderปัญหาของการมี 212 atoms
- Atom ไม่ grouped — ไม่มี
atomFamilyหรือselectorช่วยจัด ทำให้ import ยาก (import { employeeIdAtom, employeeFirstNameAtom, ... } from './recoil/employee') - Atom ไม่มี TypeScript type — JavaScript pure ทำให้ type ของ atom value ไม่ชัดเจน
- บาง atom ไม่ได้ใช้ — ไม่มี dead-code elimination ทำให้ bundle เก็บ atom ที่ไม่ได้ใช้
- State sync ระหว่าง atom — บางทีต้องใช้
selectorเพื่อ derive state แต่ selector ใช้น้อยใน codebase
Part 4 · MUI v5 ใน repo — ใช้น้อยมาก
vtrc-rc-backoffice/package.json มี @mui/*@5.11 dependencies แต่ใช้แค่ไม่กี่ไฟล์ — ส่วนใหญ่อยู่ใน WorkForce module
"@mui/material": "^5.11.0",
"@mui/icons-material": "^5.11.0",
"@mui/x-date-pickers": "^5.0.0",
"@emotion/styled": "^11.10.0",
"@emotion/react": "^11.10.0"ทำไมมี 2 UI library (AntD + MUI)
- AntD v4 — ใช้ใน module เก่า (Leave, Hospital, ManageUser, Document, Policy) — pattern เดิมของ codebase
- MUI v5 — เริ่มใช้ใน WorkForce module (ปี 2022) ตอนเดียวกับที่เริ่มใช้ Recoil — ทีมต้องการทดลอง pattern ใหม่ทั้ง state และ UI
ผลคือ bundle ใหญ่ขึ้น ~300KB (MUI v5 + Emotion) แม้จะใช้แค่ไม่กี่ไฟล์
คำแนะนำเมื่อทำงาน
- ห้ามผสม AntD + MUI ใน component เดียว — เลือกอย่างเดียว
- Module เก่า → AntD, module ใหม่ → MUI ถ้าเหมาะ — แต่ต้องทำความเข้าใจว่าเพิ่ม bundle 300KB
- ควรพิจารณา consolidate — เลือก library เดียว (AntD หรือ MUI) แล้ว migrate ทีละ module — ตัด dependency อีกตัวออก
เปรียบเทียบกับ vtrc-web
| ด้าน | vtrc-rc-backoffice | vtrc-web |
|---|---|---|
| GraphQL queries | 121 (รวม WorkForce 40) | ~150 |
| GraphQL mutations | 94 | ~60 |
| Component style | mix class + function+Recoil | class ล้วน |
| State library | Recoil 0.7 + this.state | ไม่มี (this.state เท่านั้น) |
| Atom count | 212 atoms / 57 folders | 0 (ไม่มี) |
| UI library | AntD v4 + MUI v5 (ใช้น้อย) | AntD v3 เท่านั้น |
| Form API | Form.useForm() (function) + getFieldDecorator (class) | getFieldDecorator ล้วน |
| Typo folder | TheamCss page | components/user/from/ |
| Bundle | ~11-13 MB | ~12 MB |