หากคุณเคยใช้เวลาคลุกคลีกับ React คุณคงเคยเห็นคำเตือนสีเหลืองใน console: “Each child in a list should have a unique ‘key’ prop.” มันฟังดูเหมือนเป็นเพียงคำแนะนำที่สุภาพ แต่จริงๆ แล้ว React กำลังเตือนคุณว่ามันไม่สามารถแยกแยะรายการใน list ของคุณออกจากกันได้ หากคุณเพิกเฉยต่อมัน ในที่สุดคุณจะพบกับ bug ที่ไล่ตามหาต้นตอได้ยากมาก เช่น state กระโดดไปผิดแถว, ช่อง input สูญเสีย focus หรือ animation ทำงานผิด element

Engine การเรนเดอร์ของ React ไม่ได้เปรียบเทียบ UI ของคุณแบบ pixel ต่อ pixel แต่มันจะสร้างโครงสร้างวัตถุที่เบาบางที่เรียกว่า virtual DOM ขึ้นมา จากนั้นจึงเปรียบเทียบ tree ใหม่กับ tree ก่อนหน้า และคำนวณหาชุดการเปลี่ยนแปลงที่น้อยที่สุดที่จำเป็นสำหรับ real DOM เมื่อคุณเรนเดอร์ list React จะมองเห็นเป็น array ของ sibling elements หากไม่มี key มันจะไม่มีวิธีที่เชื่อถือได้เลยที่จะรู้ว่ารายการนั้นถูกย้าย, ถูกแทนที่ หรือถูกลบออกไป มันจะใช้วิธีจับคู่ตามตำแหน่ง (position) เป็นค่าเริ่มต้น ซึ่งเป็นวิธีที่เปราะบางมาก Key ทำหน้าที่เป็นตัวระบุตัวตนที่คงที่ (stable identities) ซึ่งจะบอก React ว่า “element นี้คือตัวเดิมกับก่อนหน้านี้ แม้ว่าตอนนี้มันจะอยู่ในตำแหน่งที่ต่างออกไปก็ตาม” หากคุณทำส่วนนี้ผิด คุณกำลังเปลี่ยนจากการอัปเดตที่คาดเดาได้ (deterministic updates) ให้กลายเป็นการเดาสุ่มแทน

The Minimum Fix

คำเตือนนี้มักจะปรากฏขึ้นภายในการเรียกใช้ map คุณต้องกำหนดค่าที่ไม่ซ้ำกันให้กับ attribute key บน element ระดับบนสุดที่ถูกส่งกลับมาจาก iterator

นี่คือรูปแบบที่คุณจะเห็นในทุก codebase ที่ทำให้เกิดคำเตือน:

const UserList = ({ users }) => {
  return (
    <ul>
      {users.map((user) => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};

React เห็นแท็ก <li สามอันและไม่รู้ว่าอันไหนเป็นอันไหน การแก้ไขคือการเพิ่ม attribute เพียงตัวเดียว:

const UserList = ({ users }) => {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

ต้องกำหนด key ให้กับ element ที่อยู่ภายใน callback ของ map โดยตรง หากคุณแยก <li ออกไปเป็น component UserItem แยกต่างหาก key ก็ยังคงต้องอยู่ที่ตัว component ณ จุดที่เรียกใช้งาน:

{users.map((user) => (
  <UserItem key={user.id} user={user} />
))}

การวาง key ไว้ข้างใน UserItem ตรง <div ภายในนั้น จะไม่ช่วยให้คำเตือนหายไปและไม่ช่วยแก้ปัญหาเรื่อง reconciliation ด้วย เพราะ React จะมองหา key จาก element ที่ถูกส่งกลับมาจาก iterator

Why Index as a Key Is Dangerous

มันเป็นเรื่องง่ายที่จะทำให้คำเตือนหายไปโดยการหยิบ argument ตัวที่สองของ map มาใช้:

{users.map((user, index) => (
  <li key={index}>{user.name}</li>
))}

วิธีนี้ช่วยกำจัดเสียงรบกวนใน console ได้ แต่ไม่ได้แก้ปัญหาที่ต้นเหตุ เพราะ index ของ array ไม่ใช่ตัวระบุตัวตน (identities) แต่มันคือตำแหน่ง (positions) และตำแหน่งสามารถเปลี่ยนแปลงได้

ลองจินตนาการถึง list ของผู้ใช้สามคนซึ่งเรนเดอร์ตามลำดับนี้:

  1. Alice (index 0)
  2. Bob (index 1)
  3. Charlie (index 2)

หากคุณลบ Alice ออก Bob จะเลื่อนมาอยู่ที่ index 0 และ Charlie จะเลื่อนมาอยู่ที่ index 1 React จะเปรียบเทียบ tree ใหม่กับ tree เก่า มันเห็นว่า index 0 ตอนนี้ถือข้อมูลของ Bob อยู่ ดังนั้นมันจึงทำการ mutate (แก้ไข) DOM node เดิมที่เคยแสดงข้อมูลของ Alice หาก node นั้นกำลังมี focus อยู่ cursor จะยังคงค้างอยู่ที่แถวแรก แต่ข้อความจะเปลี่ยนเป็นของ Bob หากแถวนั้นมี <input ที่มี local state ตัว state นั้นก็จะยังคงติดอยู่กับ index 0 ผู้ใช้จะพิมพ์ลงในสิ่งที่ดูเหมือนจะเป็นแถวของ Bob แต่จริงๆ แล้ว state นั้นเป็นของ Alice ความวุ่นวายแบบเดียวกันนี้จะเกิดขึ้นเมื่อคุณทำการ sort, filter หรือเพิ่ม item ไว้ด้านหน้า (prepend) วิธีเดียวที่การใช้ index เป็น key จะปลอดภัยคือ list ที่เป็นแบบ static จริงๆ คือไม่มีการจัดลำดับใหม่, ไม่มีการ filter, ไม่มีการแทรก และไม่มีการลบ เช่น ลิงก์นำทาง (navigation links) ที่ถูก hardcode ไว้และไม่เคยเปลี่ยน ส่วนอย่างอื่นจำเป็นต้องมี identifier ที่แท้จริง

Where to Find a Stable Key

Key ที่ดีที่สุดคือ unique identifier ที่มีอยู่ใน data model ของคุณอยู่แล้ว Primary keys ของ database เช่น id นั้นเหมาะสมที่สุด เพราะพวกมันการันตีความไม่ซ้ำกันและคงอยู่ตลอดการเรนเดอร์ หาก backend ของคุณส่ง object ที่มี uuid, slug หรือฟิลด์อื่นที่มีความเฉพาะตัวตามธรรมชาติ ให้ใช้ฟิลด์เหล่านั้นแทน

เมื่อ API response ของคุณไม่มีฟิลด์ที่ระบุตัวตนได้เลย คุณมีสองทางเลือกที่ใช้งานได้จริง หนึ่งคือคุยกับทีม backend และขอให้พวกเขาใส่ id มาให้ การส่งข้อมูลแบบ relational โดยไม่มี primary key ถือเป็นสัญญาณที่ไม่ดี (smell) และการแก้ไขที่ต้นทางจะช่วยลดความคลุมเครือในทุกส่วนของ stack ของคุณ สอง หากคุณกำลังสร้าง item ขึ้นมาบน client side ทั้งหมด เช่น todo list ที่ผู้ใช้สร้าง task ก่อนที่จะส่งไปยัง server ให้สร้าง ID ขึ้นมาหนึ่งครั้งในตอนที่สร้าง (creation time) Library อย่าง uuid หรือ nanoid ถูกสร้างมาเพื่อสิ่งนี้โดยเฉพาะ ให้สร้าง ID เมื่อผู้ใช้ส่งฟอร์ม เก็บมันไว้ใน object และใช้มันเป็น key ตลอดไป

อย่าสร้าง key ภายใน render path การเรียก Math.random() หรือ Date.now() ระหว่างการเรนเดอร์ component จะทำให้เกิดค่าใหม่ในทุกๆ รอบการทำงาน React จะเห็น key ใหม่ และทึกทักเอาเองว่าเป็น element ใหม่เอี่ยม จึงทำลาย DOM node เก่าทิ้งและสร้างอันใหม่ขึ้นมาแทน ส่งผลให้ state ใดๆ ที่อยู่ภายใน element นั้นถูก reset, focus สูญหาย และประสิทธิภาพลดลงอย่างมากเพราะ React ต้องทำงานกับ DOM โดยไม่จำเป็น การใช้ key ที่สุ่มขึ้นมานั้นแย่ยิ่งกว่าการไม่ใช้ key เลยเสียอีก

Fragments, Components, and Scope

กับดักที่สังเกตเห็นได้ยากกว่าคือเรื่อง React Fragments หากคุณใช้ map กับข้อมูลและต้องการส่งคืน element ที่เป็น sibling กันหลายตัวโดยไม่ใช้ <div> มาครอบ คุณอาจจะเลือกใช้ syntax แบบย่อ:

{items.map((item) => (
  <>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </>
))}

syntax แบบย่อ <>...</> ไม่รองรับ props ซึ่งหมายความว่าคุณไม่สามารถใส่ key ได้ ในกรณีนี้ ให้เปลี่ยนไปใช้ syntax แบบเต็มแทน:

{items.map((item) => (
  <React.Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </React.Fragment>
))}

React จำเป็นต้องมี key นั้นบน Fragment เพื่อให้สามารถติดตามคู่ของ element นั้นในฐานะหน่วยเดียวกันในระหว่างการ render

อีกจุดหนึ่งที่ต้องระวังคือ: key ไม่ใช่ prop ในความหมายปกติ หากคุณเขียน <ListItem key={item.id} /> คอมโพเนนต์ ListItem จะไม่สามารถอ่าน props.key ได้ เพราะ React จะนำไปใช้ภายในเพื่อการจัดการข้อมูล (bookkeeping) หากคอมโพเนนต์ของคุณจำเป็นต้องใช้ identifier สำหรับ logic ของตัวเองจริงๆ ให้ส่งแยกต่างหากด้วยชื่ออื่น เช่น itemId

กฎที่นำไปใช้ได้จริงเพื่อความปลอดภัยของคุณ

  • ควรใช้ database ID เพราะมีความเป็นเอกลักษณ์ (unique) เป็นตัวเลขหรือสตริง และมีความเสถียร
  • ใช้ uuid หรือ nanoid สำหรับข้อมูลที่มีเฉพาะฝั่ง client โดยให้สร้าง ID เพียงครั้งเดียวเมื่อมีการสร้าง record นั้นๆ ไม่ใช่สร้างภายในขั้นตอนการ render ของคอมโพเนนต์
  • อย่าใช้ array index มาเป็น key หากรายการนั้นสามารถเปลี่ยนแปลงได้ การเรียงลำดับ (sorting), การกรอง (filtering) และการลบ จะทำให้เกิดบั๊กทั้งในส่วนของการแสดงผลและสถานะ (state)
  • อย่าใช้ Math.random(), Date.now() หรือค่าใดๆ ที่เปลี่ยนแปลงระหว่างการ render เพราะจะทำให้เกิดการ unmounting และ remounting ที่ไม่จำเป็น
  • จำไว้ว่า Fragments ต้องใช้รูปแบบเต็ม หากใช้งานอยู่ภายใน map และจำเป็นต้องมี key
  • วาง key ไว้ที่ element ที่ถูกส่งคืนมาจาก map ไม่ใช่ภายในคอมโพเนนต์ลูก

สรุปประเด็นสำคัญ

prop key ไม่ใช่แค่กฎ lint เพื่อความสวยงาม แต่มันคือวิธีที่ React ใช้รักษาตัวตน (identity) ของ element ในระหว่างการ render ให้ลองนึกภาพว่ามันเหมือนกับ primary key ในตารางฐานข้อมูล เมื่อตัวตนนั้นมีความเสถียร React จะสามารถเคลื่อนย้าย, อัปเดต และลบ element ได้อย่างแม่นยำ แต่เมื่อมันหายไปหรือไม่เสถียร คุณจะต้องแลกมาด้วยสถานะ UI ที่ผิดเพี้ยนและการทำ reconciliation ที่ล่าช้า แก้ไขปัญหานี้ให้จบที่ชั้นข้อมูล (data layer) แล้วรายการของคุณจะทำงานได้อย่างคาดเดาได้ ไม่ว่าข้อมูลจะเพิ่มขึ้นหรือเปลี่ยนแปลงไปมากเพียงใด