7.6 · Component + page architecture
บทนี้เล่าว่า component และ page ใน vtrc-web ออกแบบอย่างไร ใช้ pattern อะไร และทำไมการเข้าใจ pattern เหล่านี้จึงสำคัญก่อนแก้ไขโค้ด
ถ้าเพิ่งเริ่มทำงานกับ repo นี้ — บทนี้คือสิ่งที่ต้องอ่านก่อนเขียน component ใหม่
สถิติ pattern
จากการสแกนไฟล์ .js ใน src/:
| Pattern | จำนวนไฟล์ | สัดส่วน |
|---|---|---|
withApollo(class ...) | 250+ | ~95% |
Form.create(...) (AntD v3 HOC) | 100+ | ~40% ของ page |
| Function component | 1 (App/index.js) | < 1% |
<Query> / <Mutation> render props | 0 | 0% |
useQuery / useMutation (hooks) | 0 | 0% |
<ErrorBoundary> ระดับ root | 0 | 0% |
ข้อสรุป
vtrc-web เป็น class component + HOC ทั้งหมด — ไม่มี hooks, ไม่มี render props, ไม่มี function component ยกเว้น App/index.js
ถ้าเห็น pattern อื่นใน repo อาจเป็น code ทดลอง หรือเป็น component ที่เขียนใหม่บางส่วน — ให้ถือว่า pattern หลักคือ class + withApollo
Pattern 1 — Class component + withApollo
import React, { Component } from 'react'
import { withApollo } from 'react-apollo'
import { Queries } from '../../graphql'
class SlipPage extends Component {
state = {
slip: null,
loading: false,
}
componentDidMount() {
this.fetchSlip()
}
fetchSlip = async () => {
this.setState({ loading: true })
const { data, errors } = await this.props.client.query({
query: Queries.fetchSlip,
variables: {
month: this.state.month,
year: this.state.year,
identityCard: this.props.identityCard,
profileKey: this.props.profileKey,
},
})
if (errors) {
// errorLink จัดการแล้ว
this.setState({ loading: false })
return
}
this.setState({ slip: data.fetchSlip, loading: false })
}
render() {
const { slip, loading } = this.state
if (loading) return <div>Loading...</div>
return <div>{/* render slip */}</div>
}
}
export default withApollo(SlipPage)จุดสำคัญ
withApolloHOC ฉีดthis.props.client(ApolloClient instance) เข้าไป- เรียก query ผ่าน
this.props.client.query({ query, variables })— คืน promise - เรียก mutation ผ่าน
this.props.client.mutate({ mutation, variables })— คืน promise - error handling อยู่ใน errorLink (ดู บท 6.3) — component แค่เช็ค
errorsแล้ว return - state จัดการใน
this.state+this.setState— ไม่มี Redux/Context/Recoil
fetchPolicy default
ทุก query ใช้ fetchPolicy: 'no-cache' (ตั้งใน apolloClient.defaultOptions) — ดึงใหม่จาก server เสมอ ถ้าจะใช้ cache ต้อง override ในแต่ละ query:
this.props.client.query({
query: Queries.dropdownListProvince,
fetchPolicy: 'cache-first', // override default
variables: { ... },
})Pattern 2 — AntD Form.create HOC
AntD v3 ใช้ Form.create() HOC เพื่อฉีด form prop เข้าไปใน component ทำให้สามารถ validate field, getFieldValue, setFieldsValue ฯลฯ:
import React, { Component } from 'react'
import { Form, Input, Button } from 'antd'
import { withApollo } from 'react-apollo'
import { Mutations } from '../../graphql'
class LeaveFormBase extends Component {
handleSubmit = (e) => {
e.preventDefault()
this.props.form.validateFields(async (err, values) => {
if (err) return
const { data, errors } = await this.props.client.mutate({
mutation: Mutations.createLeaveDocument,
variables: values,
})
if (errors) return
alertMessage('success', 'บันทึกสำเร็จ')
this.props.history.push('/leave/search')
})
}
render() {
const { getFieldDecorator } = this.props.form
return (
<Form onSubmit={this.handleSubmit}>
<Form.Item label="ประเภทการลา">
{getFieldDecorator('leaveType', {
rules: [{ required: true, message: 'กรุณาเลือกประเภท' }],
})(<Input />)}
</Form.Item>
<Button type="primary" htmlType="submit">บันทึก</Button>
</Form>
)
}
}
const LeaveForm = Form.create({ name: 'leave_form' })(LeaveFormBase)
export default withApollo(LeaveForm)ลำดับ HOC
ลำดับ HOC สำคัญ — Form.create() อยู่ข้างใน withApollo ข้างนอก:
// ถูก
export default withApollo(Form.create({ name: 'xxx' })(MyComponent))
// ผิด (Form.create อยู่ข้างนอก จะทำให้ withApollo ไม่ทำงาน)
export default Form.create({ name: 'xxx' })(withApollo(MyComponent))ข้อจำกัดของ AntD v3 Form
- ไม่รองรับ nested field ที่ซับซ้อน (ต้องใช้
name: ['user', 'firstName']array syntax) - validation rule แต่ละ field เขียนใน
getFieldDecorator— ไม่มี schema-based validation - หากจะ reset ต้องใช้
this.props.form.resetFields()ไม่มี auto-reset
Pattern 3 — Page shell ของ SiderLayout
ทุก page ที่อยู่ใต้ shell จะ render ภายใน SiderLayout (ดู บท 6.4) — page component เองไม่ต้องวาด header/sider/content ซ้ำ
class LeaveFormPage extends Component {
render() {
return (
<div className="page-container">
<h1>ยื่นใบลา</h1>
{/* content */}
</div>
)
}
}สิ่งที่ SiderLayout จัดให้
- AntD
Layout+Sider+Header+Content - เมนูซ้าย (toggle ได้)
- header มีโลโก้ + ชื่อผู้ใช้ + ปุ่ม logout
- breadcrumb (ในบางหน้า)
- profile + role flag query ทำครั้งเดียวใน SiderLayout (cached ผ่าน component state)
สิ่งที่ page ต้องทำเอง
- ดึงข้อมูลเฉพาะของ page ผ่าน
this.props.client.query - วาด content ภายใน
<Content> - จัดการ state ของ page เอง
Pattern 4 — Multi-tab form (LeaveStudies, PMS)
บาง page มี form ซับซ้อนแบ่งเป็นหลาย tab โดยใช้ folder structure:
src/pages/user/LeaveStudies/Form/
├── index.js container + tab orchestrator
├── Tab1/index.js tab 1 (ข้อมูลทั่วไป)
├── Tab2/index.js tab 2 (ข้อมูลการศึกษา)
├── Tab3/index.js tab 3 (เอกสาร)
├── Tab4/index.js tab 4 (ผู้อนุมัติ)
├── Tab5/index.js tab 5
├── Tab6/index.js tab 6
├── Tab7/index.js tab 7
└── Tab8/index.js tab 8container index.js เก็บ state รวมของ form แล้วส่ง props ลงไปในแต่ละ tab — ถ้าแก้ form ต้องระวัง state sync ระหว่าง tab
Pattern 5 — Section sub-components (Recruit, Welfare)
Form ใหญ่ ๆ บางตัวแบ่งเป็น section ย่อยแบบ flat:
src/pages/user/Recruit/Form/
├── index.js container + orchestrator
└── sector/
├── secProfile.js ข้อมูลส่วนตัว
├── secEducation.js การศึกษา
├── secWorkExperience.js ประสบการณ์
├── secContact.js ติดต่อ
├── secAttachment.js ไฟล์แนบ
├── secCurrentJob.js งานปัจจุบัน
└── secCareer.js ตำแหน่งที่สนใจแต่ละ section เป็น component ของตัวเอง รับ props จาก container — คล้ายกับ multi-tab แต่แสดงพร้อมกันในหน้าเดียว
โครงสร้าง component
components/element/ — reusable UI สั้น ๆ
ไฟล์เล็ก 3-4 ตัว เช่น typography, button wrapper — ใช้น้อย ส่วนใหญ่หน้าใช้ AntD component ตรง ๆ
components/user/ — domain components (36 โฟลเดอร์)
component ที่ใช้ซ้ำในหลาย page เช่น:
| Component | หน้าที่ |
|---|---|
SiderLayout/ (1,226 บรรทัด) | shell ของทุกหน้า |
LoginForm/ | form login (ใช้ใน Login page) |
FormInputAddress/ | ฟอร์มที่อยู่ (ใช้ในหลาย page) |
FormInputTax/ | ฟอร์มภาษี |
FormHospital/, FormHospitalCreate/ | ฟอร์มโรงพยาบาล |
ConfirmUserA1/, SetPasswordsFirstTimeA1/ | onboarding flow |
FormForgetpassword/Page1, Page2, Page3 | forget password wizard |
ModalConfirmForm/ | modal ยืนยันทั่วไป |
Input/, Datetime/, InputUpload/, InputDateStartEnd/ | input wrapper |
Leave/TableCCEmail/ | ตาราง CC email |
Hospital/TalbePeople/, TableSummary/, TableMedicalCertificate/ | ตารางโรงพยาบาล (สังเกตว่า Talbe สะกดผิดจาก Table แต่เป็นชื่อจริง) |
components/user/from/ — typo ที่ห้ามแก้
โฟลเดอร์ from/ (สะกดผิดจาก Form) เป็นชื่อจริงที่ใช้ใน import path ทั้งระบบ — ห้ามแก้ชื่อโฟลเดอร์เด็ดขาด ถ้าแก้จะทำให้ทุก page ที่ import จากโฟลเดอร์นี้พัง
ไฟล์ใน from/ ส่วนใหญ่เป็น input wrapper ที่ใช้ในหลาย page:
src/components/user/from/
├── Input/index.js input text ทั่วไป
├── Datetime/index.js datetime picker (มี indexV2.js สำหรับ v2)
├── InputDateStartEnd/ ช่วงวันที่
├── InputUpload/ upload ไฟล์
├── SelectStatus/ dropdown status
├── Switchbox/ switch
├── SMS/, OTP/ input สำหรับ OTP
└── MeetingManagement/ component เฉพาะ meeting (หลายตัว)การจัดการ state — local เท่านั้น
vtrc-web ไม่มี Redux, Recoil, Context หรือ state library ใด ๆ state ทั้งหมดอยู่ใน:
- Component state —
this.state+this.setState - localStorage / sessionStorage — token, profile flag, noti counter
- URL param —
this.props.match.params.<name>(เช่น:postId,:rowID)
ข้อจำกัด
- ถ้าต้องการแชร์ state ระหว่าง page ต้องเก็บใน localStorage หรือ query ใหม่จาก server
- ไม่มี "global loading" — แต่ละ page จัด loading ของตัวเอง
- ไม่มี "global error banner" — error แสดงผ่าน
alertMessage()(wrapper ของ AntDmessage)
Routing props ที่ใช้บ่อย
page ที่ render โดย <Route> จะได้ props 3 ตัวอัตโนมัติ:
| Prop | ที่มา | ใช้ทำอะไร |
|---|---|---|
match.params | react-router | อ่าน path param (:id, :postId) |
history | react-router | navigate (history.push('/...'), history.goBack()) |
location | react-router | อ่าน query string, pathname |
ตัวอย่างการอ่าน param:
componentDidMount() {
const { postId } = this.props.match.params
this.fetchDetail(postId)
}เคล็ดลับตอนทำงานจริง
ตอนสร้าง page ใหม่
- สร้าง folder
src/pages/user/<Feature>/index.js - เขียน class component +
withApollo - เพิ่ม route entry ใน
routes.js(ดู บท 6.4) - ถ้ามี form เพิ่ม
Form.create()HOC
ห้าม ใช้ hooks (useState, useEffect) หรือ function component — เป็นการผิด pattern ทั้งระบบ
ตอนดึงข้อมูลใน component
เลือกตามจังหวะ:
| จังหวะ | Pattern |
|---|---|
| ตอน mount | componentDidMount + this.props.client.query |
| ตอน user คลิก | onClick handler + this.props.client.query |
| ตอน user submit form | validateFields callback + this.props.client.mutate |
| ตอน prop เปลี่ยน | componentDidUpdate(prevProps) + เปรียบเทียบ + query |
ตอนจัดการ form error แบบ field-level
AntD v3 form แสดง error ใต้ field อัตโนมัติ แต่ถ้าได้ error จาก server ต้อง set เอง:
this.props.form.setFields({
empCode: {
errors: [new Error('รหัสพนักงานนี้มีอยู่แล้ว')],
},
})สำหรับ error ที่ไม่ใช่ field-specific ให้ใช้ alertMessage('error', msg) หรือ message.error(msg)
ตอนเลือกใช้ message vs alertMessage
message.error(msg)— AntD toast ปกติalertMessage('error', msg)— wrapper ในutils/message.jsที่ใช้ใน errorLink
แนะนำให้ใช้ alertMessage เพื่อความสม่ำเสมอ — ถ้าวันหน้าทีมอยากเปลี่ยน toast library แก้ที่เดียว
ตอน import component จาก from/
ระวังสะกดผิด — โฟลเดอร์ชื่อ from ไม่ใช่ Form:
// ถูก
import Input from '../../components/user/from/Input'
// ผิด (จะ import ไม่ได้)
import Input from '../../components/user/Form/Input'ตอน debug "this.props.client is undefined"
อาจเป็นเพราะลืม withApollo HOC — เช็คบรรทัดสุดท้ายของไฟล์ว่ามี:
export default withApollo(MyComponent)ถ้ามี Form.create ด้วย ต้องครอบ withApollo(Form.create(...)(MyComponent))