Skip to content

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 operationsExport count
queries.js4,108121121
mutations.js2,2089494
WorkForce/queries.js~1,500~40~40
WorkForce/mutations.js~900~30~30
index.js5

หมายเหตุ: V1 ระบุ 120 queries แต่การนับใหม่ด้วย rg "^const \w+ = gql\" queries.js | wc -l` ได้ 121 — V1 ผิดเล็กน้อย

รูปแบบนิยาม

vtrc-rc-backoffice/src/graphql/queries.js (pattern):

javascript
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 อื่น ๆ
};

ข้อระวังเรื่องรูปแบบ

  1. ใช้ export default { ... } ไม่ใช่ export const — grep หา ^export const ไม่เจอ ต้อง grep ^const \w+ = gql\`` หรืออ่าน export default` block ท้ายไฟล์
  2. ชื่อ query ในไฟล์เป็น UPPER_SNAKE_CASE (FETCH_USERS, FETCH_NEWS) แต่ operation name ใน gql block เป็น PascalCase (FetchUsers) — pattern นี้ทำให้แยก component consumer (Queries.FETCH_USERS) กับ GraphQL wire ได้ชัด
  3. มี snapshot หลายตัวqueries_bk.js, queries_bk-061265.js, mutations_bk.js, queries_bk-prd.js — ห้ามใช้เป็นต้นแบบ เพราะอาจมี field deprecated

วิธี import ใน component

แบบที่ 1: import ทั้ง object (พบบ่อยในไฟล์เก่า / class component):

javascript
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):

javascript
import { Queries, Mutations } from "../../graphql";

// ใช้ผ่าน useQuery hook
const { data, loading, error } = useQuery(Queries.FETCH_USERS, {
  variables: { page: 1, limit: 20 }
});

กลุ่มฟีเจอร์หลัก (10 กลุ่ม)

กลุ่มตัวอย่าง operationจำนวนโดยประมาณ
Auth/user/roleLOGIN_BACKOFFICE, REFRESH_TOKEN_BO, FETCH_USERS, CREATE_USER, UPDATE_USER_ROLE25
Organization/career/courseFETCH_ORGANIZATIONS, FETCH_CAREERS, FETCH_COURSES20
Document/policyFETCH_POLICIES, CREATE_POLICY, FETCH_POLICY_CATEGORIES15
Hospital/medical claimFETCH_HOSPITAL_HR, FETCH_HOSPITAL_PAID, FETCH_HOSPITAL_FAILED18
LeaveFETCH_LEAVE_STUDIES_PAYMENTS, FETCH_LIST_STUDY_LEAVE_REQUEST, APPROVE_LEAVE15
Welfare/fundFETCH_WELFARE, FETCH_FUND, UPDATE_DELEGATES_EXPIRE10
News/notificationFETCH_NEWS, CREATE_NEWS, SEND_NOTIFICATION_BLAST10
System/dashboardFETCH_DASHBOARD_STATS, FETCH_LOGS5
RecruitmentFETCH_RECRUITMENT_APPLICANTS, UPDATE_RECRUITMENT_STATUS8
WorkForce module (แยก sub-folder)FETCH_EMPLOYEES, FETCH_POSITIONS_MANAGE, FETCH_PROFESSIONAL_LICENSES, FETCH_MANPOWER_MANAGE, FETCH_LEARNING40

Typo ที่ต้อง maintain

  • refundScholaship (ควรเป็น refundScholarship) — V1 ระบุไว้แล้ว ยืนยันจากการ grep โค้ดปัจจุบัน — query name จริงในไฟล์
  • WorkForce path: Perfessional (ควรเป็น Professional) — path จริงใน graphql/WorkForce/ แต่ query name สะกดถูก (FETCH_PROFESSIONAL_LICENSES)
  • TheamCss page — typo ของ ThemeCss แต่ route และ import อ้างชื่อนี้

ปัญหาที่ต้องระวังเมื่อแก้ query/mutation

  1. field ไม่ minimal — query อย่าง fetchLeaveStudiesPayments ดึง relation ลึก (user.position.organization, attachments, paymentHistory ฯล啡) ในก้อนเดียว ทำให้ response ใหญ่และ resolver ฝั่ง server หนัก
  2. OtherWelfareInput มี field 50+ — validation ฝั่ง server หนัก และ error 1 field ทำให้ mutation ทั้งก้อนล้มเหลว
  3. N+1 pattern — บาง list component ดึง relation (เช่น approver) แยก query ต่อแถวแทนฝัง relation ในผลลัพธ์หลัก
  4. ห้ามลบ field โดยไม่ grep หา consumer ก่อน — เหมือน vtrc-web เพราะไม่มี TypeScript ตรวจ compile-time
  5. ไม่มี fragment ใช้เลย — pattern เดียวกับ vtrc-web ถ้าจะเริ่มใช้ต้องคิดเป็น convention ใหม่ทั้งไฟล์
  6. WorkForce module query แยก sub-folderWorkForce/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()

ตัวอย่าง:

javascript
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()

ตัวอย่าง:

javascript
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 ตามเพราะ:

  1. ใช้งานจริงอยู่ — refactor มีความเสี่ยงสูง
  2. Testing ไม่พอ — ไม่มี test suite ครอบคลุม ทำให้ regression หลุดได้
  3. 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

javascript
// 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 masteremployeeIdAtom, employeeFirstNameAtom, employeeLastNameAtom, employeePositionAtom60
Position managepositionIdAtom, positionNameAtom, positionLevelAtom25
Professional licenselicenseIdAtom, licenseTypeAtom, licenseExpiryAtom20
ManpowermanpowerIdAtom, manpowerYearAtom, manpowerCountAtom15
LearninglearningIdAtom, learningNameAtom, learningDateAtom12
Form statecurrentStepAtom, isSubmittingAtom, formErrorAtom10
UI stateisModalVisibleAtom, selectedTabAtom8
Filter/searchsearchKeywordAtom, filterStatusAtom7
Master datacareerOptionsAtom, organizationOptionsAtom5
Misc(กระจาย)50

Pattern ที่ใช้บ่อย

Read + Write (useRecoilState):

javascript
const [employee, setEmployee] = useRecoilState(employeeAtom)
// อ่าน: employee
// เขียน: setEmployee({ ...employee, firstName: 'new' })

Read only (useRecoilValue):

javascript
const employee = useRecoilValue(employeeAtom)
// อ่านอย่างเดียว — ไม่สามารถ set

Write only (useSetRecoilState):

javascript
const setEmployee = useSetRecoilState(employeeAtom)
// เขียนอย่างเดียว — ประหยัด re-render

ปัญหาของการมี 212 atoms

  1. Atom ไม่ grouped — ไม่มี atomFamily หรือ selector ช่วยจัด ทำให้ import ยาก (import { employeeIdAtom, employeeFirstNameAtom, ... } from './recoil/employee')
  2. Atom ไม่มี TypeScript type — JavaScript pure ทำให้ type ของ atom value ไม่ชัดเจน
  3. บาง atom ไม่ได้ใช้ — ไม่มี dead-code elimination ทำให้ bundle เก็บ atom ที่ไม่ได้ใช้
  4. State sync ระหว่าง atom — บางทีต้องใช้ selector เพื่อ derive state แต่ selector ใช้น้อยใน codebase

Part 4 · MUI v5 ใน repo — ใช้น้อยมาก

vtrc-rc-backoffice/package.json มี @mui/*@5.11 dependencies แต่ใช้แค่ไม่กี่ไฟล์ — ส่วนใหญ่อยู่ใน WorkForce module

json
"@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-backofficevtrc-web
GraphQL queries121 (รวม WorkForce 40)~150
GraphQL mutations94~60
Component stylemix class + function+Recoilclass ล้วน
State libraryRecoil 0.7 + this.stateไม่มี (this.state เท่านั้น)
Atom count212 atoms / 57 folders0 (ไม่มี)
UI libraryAntD v4 + MUI v5 (ใช้น้อย)AntD v3 เท่านั้น
Form APIForm.useForm() (function) + getFieldDecorator (class)getFieldDecorator ล้วน
Typo folderTheamCss pagecomponents/user/from/
Bundle~11-13 MB~12 MB

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

ไป บท 4.2.3 State + Styling + Locale