เปิด codebase ของ React แทบจะชุดไหนก็ตาม คุณจะเห็นพฤติกรรมเดิมๆ นักพัฒนาต้องการติดตามค่าบางอย่าง จึงเลือกใช้ useState ต้องการตัวนับ (counter)? ใช้ useState ค่า input ชั่วคราว? ใช้ useState Boolean สำหรับเปิด/ปิด modal? ใช้ useState ไม่นานนัก คอมโพเนนต์เพียงตัวเดียวก็เต็มไปด้วย hook แยกย่อยนับสิบตัว ซึ่งแต่ละตัวจัดการข้อมูลเพียงเล็กน้อยที่อาจจะหรืออาจจะไม่จำเป็นต้องคงอยู่ตลอดการ render ผลลัพธ์ที่ได้คือโค้ดที่ดูวุ่นวาย เกิดการ re-render เกินความจำเป็น และ state กระจัดกระจายเหมือนเศษเหรียญที่วางทิ้งไว้ทั่วคอมโพเนนต์
พฤติกรรมนี้เป็นเรื่องที่เข้าใจได้ เพราะ useState คือ hook แรกที่พวกเราส่วนใหญ่ได้เรียนรู้ และมันก็ใช้งานได้จริง แต่การ "ใช้งานได้" นั้นไม่เหมือนกับความ "เหมาะสม" การปฏิบัติกับข้อมูลทุกชิ้นให้เป็น reactive state จะสร้างปัญหาที่จะปรากฏออกมาก็ต่อเมื่อคอมโพเนนต์เริ่มมีขนาดใหญ่ขึ้น
เพียงเพราะมันเปลี่ยนแปลง ไม่ได้หมายความว่ามันต้องใช้ State
ไม่ใช่ทุกตัวแปรที่เปลี่ยนไปตามเวลาจะควรอยู่ใน useState ค่าบางอย่างเป็นเพียงผลลัพธ์ของสิ่งอื่นที่คุณมีอยู่แล้ว หากคุณเก็บชื่อเต็มของผู้ใช้ไว้ใน state เพียงเพราะคุณนำ firstName และ lastName มาต่อกัน ตอนนี้คุณจะมีแหล่งข้อมูลที่ถูกต้อง (sources of truth) สองแห่ง เมื่อ firstName อัปเดตเนื่องจาก parent ทำการ re-render ค่า fullName ใน state ของคุณจะล้าสมัย (stale) จนกว่าคุณจะรัน effect อีกตัวเพื่อซิงค์มัน คุณไม่จำเป็นต้องมี sync effect แต่คุณต้องการ "ค่าที่ได้จากการคำนวณ" (derived value)
const fullName = `${firstName} ${lastName}`;
คำนวณมันระหว่างการ render หากการคำนวณนั้นใช้ทรัพยากรสูง ให้ทำ memoization แต่ อย่าสร้าง hook useState แยกต่างหากให้มัน เว้นแต่ว่าผู้ใช้จะสามารถแก้ไขชื่อเต็มนั้นได้โดยอิสระจากส่วนประกอบอื่นๆ
กฎเดียวกันนี้ใช้กับรายการที่ผ่านการกรอง (filtered lists) หากคุณเก็บทั้ง allItems และ filteredItems ไว้ใน state คุณกำลังเพิ่มภาระในการดูแลรักษาเป็นสองเท่า ให้กรองข้อมูลระหว่างการ render โดยเก็บเฉพาะ array ต้นฉบับและข้อความที่ใช้กรองไว้ใน state จากนั้นจึงคำนวณรายการที่ต้องแสดงผลออกมา วิธีนี้จะรับประกันว่ารายการที่กรองแล้วจะไม่หลุดจากความสอดคล้องกับแหล่งข้อมูลต้นฉบับ
ค่าบางอย่างไม่ควรทำให้เกิด Re-renders
useState มีไว้เพื่อบอก React โดยเฉพาะว่ามีบางอย่างเปลี่ยนแปลงและ DOM อาจจำเป็นต้องอัปเดต หากค่าหนึ่งเปลี่ยนไปแต่ไม่มีส่วนใดของ UI ที่สนใจการเปลี่ยนแปลงนั้น useRef คือเครื่องมือที่ดีกว่า
Timer และ interval เป็นตัวอย่างที่ชัดเจน การเก็บ ID ของ setInterval ไว้ใน state จะทำให้เกิดการ re-render ทุกครั้งที่คุณเริ่มหรือหยุด timer ทั้งที่ผู้ใช้ไม่เห็น ID ของ interval นั้นเลย การใช้ ref จะเก็บค่านั้นไว้โดยไม่แจ้งเตือน React ตรรกะเดียวกันนี้ใช้กับการติดตาม props ก่อนหน้า, การวัดขนาด DOM nodes ก่อนการวาดหน้าจอ (paint), หรือการเก็บ callback ล่าสุดสำหรับ custom hook ลองถามตัวเองว่า: ค่านี้จำเป็นต้องปรากฏบนหน้าจอหรือไม่? ถ้าคำตอบคือไม่ มันก็อาจไม่จำเป็นต้องใช้ useState
ตัว DOM nodes เองก็ควรอยู่ใน refs เช่นกัน แม้ว่าคุณจะสามารถเก็บ DOM element ไว้ใน state ได้ แต่การทำเช่นนั้นจะกระตุ้นให้เกิดการ re-render หลังจาก ref callback ทำงาน ในกรณีส่วนใหญ่ คุณต้องการ node เพียงเพื่อเรียกใช้เมธอดแบบ imperative หรือเพื่อการวัดขนาด ไม่ใช่เพื่อนำมา render ให้แตกต่างออกไป
กับดักของ Boolean
ความกังวลด้าน UI ที่เกี่ยวข้องกันมักจะกระจัดกระจายเมื่อทุก flag มี hook เป็นของตัวเอง คุณจะเห็นคอมโพเนนต์ที่มี isLoading, isError, และ isSuccess ถูกกำหนดเป็น boolean สามตัวแยกกัน ปัญหาก็คือสถานะทั้งสามนี้ไม่ได้เป็นอิสระต่อกัน หาก isLoading และ isSuccess เป็น true ทั้งคู่ UI ของคุณจะอยู่ในสภาวะที่เป็นไปไม่ได้ แต่ TypeScript และ React ก็ยังยอมให้คุณ render มันออกมาอยู่ดี
การจัดกลุ่ม state ที่เกี่ยวข้องกันจะช่วยป้องกันการรวมกันของสถานะที่ไม่ถูกต้องเหล่านี้ แทนที่จะใช้ boolean สามตัว ให้ติดตามด้วย string ของ status เพียงตัวเดียว เช่น: 'idle', 'loading', 'success', หรือ 'error' ซึ่งจะมีเพียงสถานะเดียวเท่านั้นที่ทำงานได้ในขณะใดขณะหนึ่ง ช่วยกำจัดสถานะที่เป็นไปไม่ได้ออกไปในระดับ type หากข้อมูลมีความซับซ้อนมากขึ้น การใช้ object ร่วมกับ discriminated union จะช่วยให้ทุกอย่างสะอาดขึ้นไปอีก เมื่อใดที่คุณพบว่าตัวเองกำลังอัปเดต useState หลายตัวภายใน event handler เดียวกัน นั่นคือสัญญาณว่าค่าเหล่านั้นควรอยู่ด้วยกัน
เลือกใช้ useReducer แทนที่จะเพิ่ม useState เข้าไปอีก
มีจุดหนึ่งที่การอัปเดต state กลายเป็นการเล่นเกมตีตัวตุ่น (whack-a-mole) คุณเรียก setA ตามด้วย setB แล้วตามด้วย setC ตามเงื่อนไข ทั้งหมดนี้อยู่ภายในฟังก์ชันเดียว นักพัฒนาคนถัดไปที่มาอ่านโค้ดนี้จะต้องไล่ลำดับเหตุการณ์เพื่อทำความเข้าใจว่าคอมโพเนนต์นี้ทำอะไรกันแน่
useReducer จะโดดเด่นมากในจุดนี้ มันไม่ได้เข้ามาแทนที่ useState เพราะมันล้ำสมัยกว่า แต่มันเข้ามาแทนที่เพราะตรรกะของโปรแกรมต้องการมัน Reducer จะรวบรวมวิธีการเปลี่ยนแปลง state ไว้ที่ศูนย์กลาง แทนที่จะกระจายคำสั่งแบบ imperative ไปทั่ว event handlers คุณเพียงแค่ dispatch ความต้องการ (intention) ออกไป เช่น dispatch({ type: 'submitted' }) แล้ว reducer จะเป็นผู้ตัดสินใจว่า state ถัดไปควรเป็นอย่างไร สิ่งนี้ทำให้การทดสอบทำได้ง่ายมาก เพราะตรรกะของ state ของคุณคือ pure function และยังทำให้การ debug ง่ายขึ้นด้วย เพราะทุกการเปลี่ยนแปลงจะทิ้ง action ที่สามารถตรวจสอบย้อนกลับได้ไว้เสมอ
คุณไม่จำเป็นต้องใช้ Redux เพื่อสร้างความจำเป็นในการใช้ reducer หากคุณมีตัวแปร state ตั้งแต่สามตัวขึ้นไปที่ต้องอัปเดตพร้อมกัน หรือหาก state ถัดไปของคุณต้องพึ่งพา state ก่อนหน้าอย่างมาก การใช้ reducer จะช่วยให้ component เรียบง่ายขึ้นอย่างมหาศาล
State จริงๆ แล้วอยู่ที่ไหน
บางครั้งปัญหาไม่ใช่เรื่องของวิธีการจัดเก็บ state แต่เป็นเรื่องของสถานที่จัดเก็บ ข้อผิดพลาดที่พบบ่อยคือการยก (hoisting) state ไปไว้ที่ component ตัวแม่ เพียงเพราะคิดว่ามันอาจจะถูกนำไปใช้ที่อื่น หากมีเพียง leaf component ตัวเดียวที่ใช้ state นั้น ก็ควรเก็บมันไว้ที่นั่น นี่คือการทำ colocation ซึ่งจะช่วยลดขอบเขตผลกระทบ (blast radius) จากการเปลี่ยนแปลง อย่าทำให้ component ตัวแม่ต้อง re-render เพียงเพราะ child component เปิด
