การจัดการสถานะใน React ให้เรียบง่าย: แยกสถานะเซิร์ฟเวอร์ออกจากสถานะ UI ยึดกฎไม่กี่ข้อ และสังเกตสัญญาณเริ่มต้นของความซับซ้อน

State คือข้อมูลที่เปลี่ยนได้ขณะที่แอปกำลังทำงาน ซึ่งรวมทั้งสิ่งที่คุณเห็น (เช่น modal เปิดอยู่), สิ่งที่กำลังแก้ไข (ร่างฟอร์ม), และข้อมูลที่ดึงมา (รายการโปรเจกต์). ปัญหาคือสิ่งพวกนี้ทั้งหมดมักถูกเรียกว่ากันว่า "state" ทั้งที่พฤติกรรมต่างกันมาก.
แอปที่ยุ่งเหยิงมักพังด้วยวิธีเดียวกัน: หลายประเภท state ถูกผสมกันในที่เดียว คอมโพเนนต์อาจถือทั้งข้อมูลจากเซิร์ฟเวอร์, ธง UI, ร่างฟอร์ม, และค่าที่ได้มาจากการคำนวณ แล้วพยายามซิงก์พวกมันด้วย effects. ไม่ช้าก็เร็ว คุณจะตอบคำถามง่าย ๆ เช่น "ค่าตัวนี้มาจากไหน?" หรือ "อะไรอัปเดตมัน?" ไม่ได้โดยไม่ไล่หาผ่านไฟล์หลายไฟล์.
แอป React ที่สร้างโดย generator มักลื่นไหลไปสู่สภาพนี้เร็วกว่าเพราะยอมรับเวอร์ชันแรกที่ใช้งานได้ง่าย: เพิ่มหน้าจอใหม่, คัดลอก pattern, แปะบั๊กด้วย useEffect อีกอัน — แล้วคุณมีสองแหล่งความจริง. ถ้า generator หรือทีมเปลี่ยนทิศทางกลางคัน (local state ที่นี่, global store ที่นั่น) โค้ดจะสะสม pattern แทนที่จะสร้างบนฐานเดียว.
เป้าหมายคือ "ทำให้เรียบง่าย": ลดชนิดของ state และลดที่ต้องค้นหา เมื่อมีบ้านที่ชัดเจนสำหรับข้อมูลจากเซิร์ฟเวอร์และตำแหน่งที่ชัดเจนสำหรับ state เฉพาะ UI บั๊กจะเล็กลงและการเปลี่ยนแปลงไม่รู้สึกเสี่ยง.
"ทำให้เรียบง่าย" หมายถึงการยึดตามกฎไม่กี่ข้อ:
ตัวอย่างปฏิบัติ: ถ้ารายการผู้ใช้มาจาก backend ให้ถือว่าเป็น server state และดึงเมื่อใช้งาน. ถ้า selectedUserId มีไว้เพื่อขับรายละเอียด ให้เก็บเป็น state UI ขนาดเล็กใกล้กับแผงรายละเอียด. การผสมทั้งสองคือจุดเริ่มต้นของความซับซ้อน.
ปัญหาส่วนใหญ่เริ่มจากการสับสนอย่างเดียว: เอาข้อมูลเซิร์ฟเวอร์ไปคิดว่าเป็น UI state. แยกตั้งแต่ต้น แล้วการจัดการ state จะสงบแม้แอปจะเติบโต.
Server state เป็นของ backend: users, orders, tasks, permissions, prices, feature flags. มันอาจเปลี่ยนได้โดยที่แอปคุณทำอะไรไม่เกี่ยว (แท็บอื่นอัปเดต, admin แก้, งานรัน, ข้อมูลหมดอายุ). เพราะมันถูกแชร์และเปลี่ยนได้ คุณต้องมีการ fetch, caching, refetching, และการจัดการข้อผิดพลาด.
Client state คือสิ่งที่ UI ของคุณเท่านั้นสนใจตอนนี้: modal ตัวไหนเปิด, แท็บที่เลือก, toggle ตัวกรอง, ลำดับการจัดเรียง, sidebar ที่ย่อ, ร่างข้อความค้นหา. ถ้าคุณปิดแท็บแล้วหายไปก็ไม่เป็นไร.
การทดสอบง่าย ๆ: "ฉันรีเฟรชหน้าแล้วสร้างสิ่งนี้จากเซิร์ฟเวอร์ได้ไหม?"
ยังมี derived state ซึ่งช่วยให้คุณไม่ต้องสร้าง state เพิ่ม ค่าที่คำนวณได้จากค่าอื่น ๆ จึงไม่เก็บไว้ ตัวอย่างเช่น รายการที่ถูกกรอง ยอดรวม isFormValid และการแสดงผลว่า "ไม่มีข้อมูล" มักเป็น derived.
ตัวอย่าง: คุณดึงรายการโปรเจกต์ (server state). ตัวกรองที่เลือกและธงว่าไดอะล็อก "โปรเจกต์ใหม่" เปิดอยู่เป็น client state. รายการที่เห็นหลังกรองเป็น derived. ถ้าคุณเก็บรายการที่เห็นแยกต่างหาก มันจะล้าสมัยและคุณจะตามหาเหตุผลว่า "ทำไมมันเก่า?".
การแยกแบบนี้ช่วยเมื่อเครื่องมืออย่าง Koder.ai สร้างหน้าจอได้เร็ว: เก็บข้อมูล backend ในชั้นการดึงข้อมูลหนึ่งที่ชัดเจน เก็บตัวเลือก UI ใกล้คอมโพเนนต์ และหลีกเลี่ยงการเก็บค่าที่คำนวณได้.
State เจ็บปวดเมื่อหนึ่งข้อมูลมีเจ้าของสองคน วิธีเร็วที่สุดในการรักษาความง่ายคือ ตัดสินว่าใครเป็นเจ้าของอะไรแล้วยึดตามนั้น.
ตัวอย่าง: คุณดึงรายการผู้ใช้และแสดงรายละเอียดเมื่อเลือก หนึ่งความผิดพลาดคือเก็บอ็อบเจ็กต์ผู้ใช้ที่เลือกทั้งอันใน state. ให้เก็บ selectedUserId แทน. รายละเอียดจะมองหาผู้ใช้จาก cache ดังนั้นการ refetch จะอัปเดต UI โดยไม่ต้องซิงก์เพิ่ม.
ในแอป React ที่สร้างโดย generator มักเห็น state ที่เครื่องมือสร้างมาดู "ช่วยเหลือ" โดยซ้ำกับข้อมูลเซิร์ฟเวอร์: เมื่อเห็นโค้ดที่ทำ fetch -> setState -> edit -> refetch หยุดก่อน. นั่นมักเป็นสัญญาณว่าคุณกำลังสร้างฐานข้อมูลที่สองในเบราว์เซอร์.
Server state คือข้อมูลที่อยู่บน backend: รายการ, หน้า detail, ผลลัพธ์การค้นหา, permissions, counts. วิธีน่าเบื่อคือเลือกเครื่องมือหนึ่งตัวและยึดตามมัน สำหรับแอป React หลายตัว TanStack Query มักพอเพียง.
เป้าหมายตรงไปตรงมา: คอมโพเนนต์ขอข้อมูล แสดง loading และ error และไม่ต้องสนใจจำนวนการเรียก fetch ใต้ผิวน้ำ นี่สำคัญในแอปที่สร้างเร็วเพราะความไม่สอดคล้องเล็ก ๆ ทวีคูณเมื่อตัวหน้าจอเพิ่มขึ้น.
ถือ query keys เหมือนระบบตั้งชื่อ อย่าเป็นของรอง: ให้คงที่เป็น array keys, ใส่แค่ input ที่เปลี่ยนผลลัพธ์ (filters, page, sort), และชอบรูปแบบที่คาดเดาได้ไม่กี่แบบมากกว่าของที่ทำครั้งเดียว หลายทีมยังใส่การสร้าง key ใน helper เล็ก ๆ เพื่อให้ทุกหน้าจอใช้กฎเดียวกัน.
สำหรับการเขียนข้อมูล (writes) ให้ใช้ mutations พร้อมการจัดการเมื่อสำเร็จที่ชัดเจน. mutation ควรตอบสองคำถาม: อะไรเปลี่ยน และ UI ควรทำอะไรต่อ?
ตัวอย่าง: คุณสร้างงานใหม่ เมื่อสำเร็จ ให้ invalidate query ของรายการงาน (ให้โหลดใหม่ครั้งหนึ่ง) หรืออัปเดตรายการใน cache แบบเจาะจง (เพิ่มงานใหม่เข้าไป). เลือกวิธีใดวิธีหนึ่งต่อฟีเจอร์และรักษาความสม่ำเสมอ.
ถ้ารู้สึกอยากเพิ่ม refetch ในหลายที่ "เผื่อให้ปลอดภัย" ให้เลือกการเคลื่อนไหวที่น่าเบื่อเดียวแทน:
Client state คือสิ่งที่เบราว์เซอร์เป็นเจ้าของ: ธง sidebar เปิด, แถวที่เลือก, ข้อความตัวกรอง, ร่างก่อนบันทึก. เก็บมันใกล้ที่ใช้งานแล้วมันมักจะจัดการได้ง่าย.
เริ่มจากเล็ก ๆ: useState ในคอมโพเนนต์ใกล้ที่สุด. เมื่อสร้างหน้าจอ (เช่น ด้วย Koder.ai) มักจะแทบผลักทุกอย่างเข้า global store "เผื่อไว้" — นั่นแหละคือจุดที่คุณจะได้ store ที่ไม่มีใครเข้าใจ.
ย้าย state ขึ้นเฉพาะเมื่อคุณตั้งชื่อปัญหาการแชร์ได้:
ตัวอย่าง: ตารางที่มีแผงรายละเอียดสามารถเก็บ selectedRowId ไว้ในคอมโพเนนต์ตาราง หาก toolbar ในอีกส่วนของหน้าอยากใช้ ให้ยกขึ้นเป็น state ของหน้าดังกล่าว ถ้ารูตแยกเช่น bulk edit ต้องการ ก็ถึงเวลาสร้าง store เล็ก ๆ
ถ้าใช้ store (เช่น Zustand) ให้โฟกัสงานเดียว เก็บ "อะไร" (selected IDs, filters) ไม่ใช่ "ผลลัพธ์" (รายการเรียง) ที่คุณสามารถ derive ได้.
เมื่อ store เริ่มโต ให้ถาม: นี่ยังเป็นฟีเจอร์เดียวไหม? ถ้าคำตอบคือ "กึ่ง ๆ" ให้แยกตอนนี้ ก่อนที่ฟีเจอร์ถัดไปจะทำให้มันกลายเป็นก้อน state ที่คุณกลัวจะเข้าไปแตะ.
บั๊กฟอร์มมักมาจากการผสมกันของสามอย่าง: สิ่งที่ผู้ใช้พิมพ์, สิ่งที่เซิร์ฟเวอร์บันทึก, และสิ่งที่ UI แสดง.
สำหรับการจัดการ state ที่น่าเบื่อ ให้ถือฟอร์มเป็น client state จนกว่าจะ submit. ข้อมูลเซิร์ฟเวอร์คือเวอร์ชันที่บันทึกล่าสุด ฟอร์มคือร่าง อย่าแก้อ็อบเจ็กต์เซิร์ฟเวอร์โดยตรง คัดลอกค่าเข้า draft state ให้ผู้ใช้แก้ แล้ว submit และ refetch (หรืออัปเดต cache) เมื่อสำเร็จ.
ตัดสินใจตั้งแต่ต้นว่าจะให้บางค่าอยู่ต่อเมื่อผู้ใช้ไปหน้าอื่นหรือไม่ การเลือกแค่นั้นช่วยป้องกันบั๊กได้มาก ตัวอย่าง: โหมดแก้ไขแบบ inline และ dropdown เปิดควรรีเซ็ต ส่วน wizard ยาวหรือร่างข้อความที่ยังไม่ส่งอาจต้องเก็บไว้ การเก็บข้าม reload ควรทำเฉพาะเมื่อผู้ใช้คาดหวังจริง ๆ (เช่น checkout).
เก็บกฎการ validate ไว้ที่เดียว ถ้ากระจายไปตาม input, submit handlers และ helpers คุณจะได้ error ที่ไม่ตรงกัน เลือก schema เดียว (หรือฟังก์ชัน validate() เดียว) แล้วให้ UI ตัดสินใจว่าเมื่อไรจะแสดง error (on change, on blur, หรือ on submit).
ตัวอย่าง: สร้างหน้าจอ Edit Profile ด้วย Koder.ai. ดึง profile ที่บันทึกเป็น server state สร้าง draft ท้องถิ่นสำหรับฟิลด์ แสดง "unsaved changes" โดยเปรียบเทียบ draft กับ saved. ถ้าผู้ใช้ยกเลิก ให้ทิ้ง draft และแสดงเวอร์ชันจากเซิร์ฟเวอร์ ถ้าบันทึก สำเร็จแล้วแทนที่ saved ด้วย response จากเซิร์ฟเวอร์.
เมื่อแอปที่สร้างเติบโต มักจะจบลงด้วยข้อมูลเดียวกันในสามที่: component state, global store, และ cache การแก้ปัญหาไม่จำเป็นต้องห้องสมุดใหม่ แต่อยู่ที่การเลือกบ้านเดียวสำหรับแต่ละชิ้นของ state.
แนวทาง cleanup ที่ใช้ได้กับแอปส่วนใหญ่:
filteredUsers ออกถ้าคุณคำนวณได้จาก users + filter. ชอบ selectedUserId มากกว่าการทำสำเนา selectedUserตัวอย่าง: แอป CRUD ที่สร้างจาก Koder.ai มักเริ่มด้วย useEffect fetch บวกสำเนาใน global store หลังจากรวม server state รายการจะมาจาก query เดียว และ "refresh" กลายเป็น invalidation แทนการซิงก์ด้วยมือ.
สำหรับการตั้งชื่อ ให้คงที่และธรรมดา:
users.list, users.detail(id)ui.isCreateModalOpen, filters.userSearchopenCreateModal(), setUserSearch(value)users.create, users.update, users.deleteเป้าหมายคือแหล่งความจริงหนึ่งแหล่งต่อสิ่งหนึ่ง พร้อมขอบเขตที่ชัดเจนระหว่าง server state และ client state.
ปัญหา state เริ่มเล็ก ๆ แล้ววันหนึ่งคุณเปลี่ยนฟิลด์เดียวและสามส่วนของ UI ไม่เห็นพ้องกันเกี่ยวกับค่าที่ "แท้จริง".
สัญญาณชัดเจนที่สุดคือข้อมูลซ้ำ: ผู้ใช้หรือตะกร้าสินค้ามีอยู่ในคอมโพเนนต์, global store และ request cache แต่ละสำเนาอัปเดตต่างเวลา และคุณเพิ่มโค้ดเพียงเพื่อให้มันเท่ากัน.
สัญญาณอีกอย่างคือโค้ดซิงก์: effects ที่ผลัก state ไปมา รูปแบบเช่น "เมื่อ query เปลี่ยน ให้อัปเดต store" และ "เมื่อ store เปลี่ยน ให้ refetch" อาจใช้ได้จนกว่าจะเจอ edge case ที่ทำให้ค่าเป็น stale หรือเกิดลูป.
สัญญาณแดงเร็ว ๆ:
needsRefresh, didInit, isSaving ที่ไม่มีใครลบทิ้งตัวอย่าง: คุณสร้างแดชบอร์ดด้วย Koder.ai แล้วเพิ่ม modal แก้ไขโปรไฟล์ ถ้าข้อมูลโปรไฟล์อยู่ใน query cache, คัดลอกเข้า global store, แล้วซ้ำใน local form state คุณมีสามแหล่งความจริง เมื่อตั้ง refetch เบื้องหลังหรือ optimistic updates ความไม่ตรงกันจะปรากฏขึ้น.
เมื่อเห็นสัญญาณเหล่านี้ การเคลื่อนไหวที่น่าเบื่อคือเลือกเจ้าของเดียวสำหรับแต่ละข้อมูลแล้วลบสำเนา.
เก็บของไว้ "เผื่อไว้" เป็นวิธีที่เร็วที่สุดในการทำให้ state เจ็บปวด โดยเฉพาะในแอปที่สร้างอัตโนมัติ.
คัดลอก response ของ API เข้า global store เป็นกับดักทั่วไป ถ้าข้อมูลมาจากเซิร์ฟเวอร์ (lists, details, profile) อย่าคัดลอกเข้า client store โดยค่าเริ่มต้น เลือกบ้านเดียวสำหรับ server data (มักเป็น query cache). ใช้ client store สำหรับค่า UI เท่านั้นที่เซิร์ฟเวอร์ไม่รู้จัก.
เก็บค่าที่ derived เป็นกับดักอีกอย่าง ยอด, รายการกรอง, canSubmit, isEmpty ควรถูกคำนวณจาก input ถ้าประสิทธิภาพกลายเป็นปัญหาจริง ๆ ให้ memoize ภายหลัง แต่ไม่เริ่มจากการเก็บผลลัพธ์.
หนึ่ง mega-store สำหรับทุกอย่าง (auth, modals, toasts, filters, drafts, onboarding flags) จะกลายเป็นที่ทิ้ง แบ่งตามขอบเขตฟีเจอร์ ถ้า state ใช้โดยหน้าจอเดียว ให้เก็บท้องถิ่น.
Context ดีสำหรับค่าคงที่ (theme, current user id, locale). สำหรับค่าที่เปลี่ยนบ่อย มันอาจทำให้เกิดการ re-render กว้าง ใช้ Context สำหรับการเดินสาย ส่วน state ที่เปลี่ยนบ่อยให้ใช้ component state หรือ store เล็ก ๆ.
สุดท้าย หลีกเลี่ยงการตั้งชื่อไม่สอดคล้องกัน keys query ใกล้เคียงและฟิลด์ store เกือบเหมือนกันจะสร้างการซ้ำแบบละเอียด เลือกมาตรฐานง่าย ๆ และทำตามมัน.
เมื่ออยากเพิ่ม "แค่ตัวแปร state อีกตัว" ให้ทำ ownership check สั้น ๆ:
แรก คุณชี้ได้ไหมว่ามีที่เดียวที่ทำการ fetching และ caching (เครื่องมือ query เดียว, ชุด query keys เดียว)? ถ้าข้อมูลเดียวถูก fetch ในหลายคอมโพเนนต์และก็ถูกคัดลอกเข้า store คุณกำลังจ่ายดอกเบี้ยอยู่แล้ว.
ที่สอง ค่านี้จำเป็นแค่ในหน้าจอเดียวไหม (เช่น "panel filter เปิด" )? ถ้าใช่ มันไม่ควรเป็น global.
ที่สาม เก็บ ID แทนการคัดลอกอ็อบเจ็กต์ได้ไหม? เก็บ selectedUserId แล้วอ่านผู้ใช้จาก cache หรือ list.
ที่สี่ มันเป็น derived ไหม? ถ้าคำนวณจาก state ที่มีอยู่ อย่าเก็บ.
สุดท้าย ทำการทดสอบ trace หนึ่งนาที ถ้าทีมเมทตอบไม่ได้ว่า "ค่านี้มาจากไหน? (prop, local state, server cache, URL, store)" ในไม่กี่วินาที ให้แก้เจ้าของก่อนเพิ่ม state ต่อ.
เริ่มจากการติดป้ายให้ชัดกับทุกชิ้นของ state ว่าเป็น server, client (UI) หรือ derived.
isValid).เมื่อแยกแล้ว ให้แน่ใจว่าแต่ละรายการมี เจ้าของที่ชัดเจนเพียงที่เดียว (query cache, state ท้องถิ่นของคอมโพเนนต์, URL หรือ store ขนาดเล็ก).
ใช้การทดสอบง่าย ๆ: “ถ้าฉันรีเฟรชหน้าแล้วสร้างใหม่จากเซิร์ฟเวอร์ได้ไหม?”
ตัวอย่าง: รายการโปรเจกต์เป็น server state; เป็น client state.
เพราะมันสร้าง แหล่งข้อมูลที่เป็นจริงสองที่.
ถ้าคุณ fetch users แล้วคัดลอกเข้า useState หรือ global store คุณต้องคอยซิงก์ระหว่าง:
กฎพื้นฐาน: และสร้าง state ท้องถิ่นแค่สำหรับเรื่อง UI หรือ draft เท่านั้น.
เก็บค่าที่ derived ไว้ก็ต่อเมื่อคุณ คำนวณมันไม่ไหวอย่างคุ้มค่า ด้วยทางเลือกอื่น.
ปกติให้คำนวณจาก input ที่มีอยู่:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSavingถ้ามีปัญหาด้านประสิทธิภาพจริง ๆ ให้วัดก่อน แล้วค่อยใช้ หรือปรับโครงสร้างข้อมูล แทนการเริ่มด้วยการเก็บค่าที่อาจล้าสมัย.
โดยปกติ: เลือกเครื่องมือสำหรับ server state ตัวเดียวแล้วใช้มันเป็นมาตรฐาน สำหรับหลายแอป React, TanStack Query มักจะพอ.
หลักการง่าย ๆ:
อยากจะ refetch() หลายที่ให้หยุดก่อน — เลือกวิธีที่น่าเบื่อแต่ชัดเจนแทน:
เก็บไว้ ท้องถิ่น จนกว่าจะมีความต้องการแชร์ที่ชัดเจน.
กฎการยก state ขึ้น:
useState ในคอมโพเนนต์ที่ใกล้ที่สุดวิธีนี้จะกันไม่ให้ global store กลายเป็นที่ทิ้งของ flag สุ่ม ๆ
เก็บ ID และ flag เล็ก ๆ แทนการเก็บอ็อบเจ็กต์ทั้งก้อน.
ตัวอย่าง:
selectedUserIdselectedUser (อ็อบเจ็กต์ที่คัดลอกมา)จากนั้นเมื่อเราจะแสดงรายละเอียด ให้ค้นหาผู้ใช้จาก cached list/detail query การ refetch เบื้องหลังจะอัปเดต UI ได้โดยไม่ต้องซิงก์เพิ่ม.
มองแบบ practical:
แบบนี้จะไม่เผลอแก้ข้อมูล server โดยตรงและไม่สู้กับการ refetch.
สัญญาณเตือน:
needsRefresh, didInit, isSavingการแก้มักไม่ต้องหาคลังเครื่องมือใหม่ แต่อยู่ที่ลบสำเนาและเลือกเจ้าของเดียวต่อค่าที่สำคัญ.
หน้าจอที่สร้างโดยเครื่องมือมักจะเดินไปผิดทางเร็ว ๆ ให้ตั้งมาตรฐานการเป็นเจ้าของ:
ถ้าใช้ Koder.ai, ใช้ Planning Mode เพื่อคุยเรื่องขอบเขตก่อนสร้างหน้าจอใหม่ และใช้ snapshot/rollback เมื่อต้องทดลองการเปลี่ยนแปลง state เพื่อย้อนกลับได้ง่ายถ้าผิดพลาด.
selected row IDuseMemo