การส่ง props ผ่านคอมโพเนนต์หลายชั้นที่ไม่ได้ต้องการข้อมูลนั้นเป็นงานที่น่าเบื่อหน่าย วันหนึ่งคุณกำลังส่งฟีเจอร์ใหม่ แต่อีกวันคุณกลับต้องไล่แก้ไขไฟล์ถึงหกไฟล์เพียงเพื่อเปลี่ยนชื่อ prop ตัวเดียว นั่นแหละคือคำจำกัดความของ Prop Drilling: คอมโพเนนต์แม่มีข้อมูล คอมโพเนนต์ลูกที่อยู่ลึกลงไปต้องการข้อมูลนั้น และทุกคอมโพเนนต์ที่อยู่ระหว่างกลางก็กลายเป็นเพียงพนักงานส่งของ แม้แอปจะยังทำงานได้ แต่โค้ดจะเริ่มเปราะบาง หากคุณลบเลเยอร์ตรงกลางออกเพียงชั้นเดียว โครงสร้างครึ่งหนึ่งของต้นไม้ (tree) ก็อาจพังทลาย หรือถ้าคุณเปลี่ยน Type ของข้อมูล TypeScript ก็จะแจ้งเตือน error ไปทั่วทั้งสามไดเรกทอรี React Context API จึงถูกสร้างขึ้นมาเพื่อกำจัดตัวกลางเหล่านี้ออกไปให้หมด

Prop Drilling หน้าตาเป็นอย่างไร

ลองจินตนาการถึงโครงสร้างหลัก (app shell) ของแอปทั่วไป คุณมีคอมโพเนนต์ App ที่ทำหน้าที่ดึงข้อมูลผู้ใช้ปัจจุบัน ภายใน App จะมี Layout อยู่ข้างใน Layout มี Sidebar อยู่ข้างใน Sidebar มี Navigation ซ้อนอยู่ และสุดท้ายภายใน Navigation คุณจะพบกับ UserAvatar ซึ่งเป็นตัวที่ต้องการออบเจกต์ผู้ใช้จริงๆ

โค้ดของคุณจะมีหน้าตาเป็นแบบนี้:

function App() {
  const user = { name: 'Aarav', role: 'admin' };
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Sidebar user={user} />;
}

function Sidebar({ user }) {
  return <Navigation user={user} />;
}

function Navigation({ user }) {
  return <UserAvatar user={user} />;
}

Layout, Sidebar และ Navigation ไม่ได้ทำอะไรกับออบเจกต์ผู้ใช้นั้นเลยนอกจากส่งมันต่อไปเรื่อยๆ พวกมันต้องแบกรับ props ที่ตัวเองไม่ได้เป็นเจ้าของ ทำให้ interface บวมขึ้น และการทำ testing ก็ต้องเสียเวลาทำ mock data สำหรับสิ่งที่พวกมันไม่ได้ใช้งานจริงๆ สิ่งที่แย่จริงๆ คือมันแพร่กระจายไปเร็วมาก เพียงแค่คุณเพิ่ม flag isLoggedIn, สตริง locale หรือค่า theme ขบวนพาเหรดของ props แบบเดิมก็จะเกิดขึ้นซ้ำอีก

Context API เปลี่ยนเกมได้อย่างไร

ให้ลองนึกถึง Context API เหมือนกับเราเตอร์ WiFi แทนที่จะต้องลากสายเคเบิลยาวๆ ผ่านทุกห้องเพื่อเชื่อมต่อกับอุปกรณ์แต่ละเครื่อง เราเตอร์จะส่งสัญญาณผ่านอากาศแทน อุปกรณ์ใดๆ ที่อยู่ในระยะก็สามารถเชื่อมต่อได้โดยตรง ในเชิงของ React เราเตอร์ก็คือ Provider, สัญญาณคือ state หรือข้อมูลของคุณ และอุปกรณ์ก็คือคอมโพเนนต์ใดๆ ที่อยู่ภายใต้ (nested) ซึ่งเรียกใช้ useContext

การตั้งค่ามีองค์ประกอบหลักสามส่วน:

  • React.createContext() ใช้สำหรับสร้างช่องทางส่งข้อมูล
  • Provider จะห่อหุ้มส่วนหนึ่งของ tree และทำหน้าที่ส่งค่า (value) ออกไป
  • useContext hook ช่วยให้คอมโพเนนต์ลูกหลานได้รับค่านั้นโดยไม่ต้องผ่าน props ของคอมโพเนนต์ระหว่างกลาง

คุณยังคงมีโครงสร้างแบบ tree อยู่ แต่กิ่งก้านระหว่างราก (root) และใบ (leaf) ไม่จำเป็นต้องทำข้อตกลงในการเป็นพนักงานส่งของอีกต่อไป

การสร้าง Context จากศูนย์

เรามาลองสร้างตัวอย่างที่เป็นรูปธรรมด้วยการตั้งค่า theme กัน เนื่องจากแอปส่วนใหญ่จำเป็นต้องมีโหมดสว่างหรือโหมดมืดในบางช่วงเวลา

ขั้นแรก สร้างออบเจกต์ context นี่คือท่อส่งข้อมูลของคุณ:

import { createContext, useState, useMemo } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export default ThemeContext;

จากนั้นห่อหุ้มแอปพลิเคชันของคุณด้วย provider โดยปกติจะทำที่บริเวณ root:

import { ThemeProvider } from './ThemeContext';

function App() {
  return (
    <ThemeProvider>
      <Layout />
    </ThemeProvider>
  );
}

ตอนนี้คอมโพเนนต์ลูกหลานใดๆ ก็สามารถรับสัญญาณได้โดยตรง นี่คือปุ่ม toggle ที่ฝังอยู่ลึกภายใน UI:

import { useContext } from 'react';
import ThemeContext from './ThemeContext';

function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button
      onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}
    >
      Current theme: {theme}
    </button>
  );
}

สังเกตว่า Layout, Sidebar และ Navigation ไม่เคยเห็น prop theme เลย พวกมัน render ตามปกติ และ ThemeToggle ก็ดึงสิ่งที่ต้องการมาจาก context โดยตรง การเชื่อมต่อนี้จะมองไม่เห็นจากภายนอก ซึ่งนั่นคือจุดประสงค์หลักของมัน

Context ควรใช้ตอนไหน

Context ทำงานได้ดีที่สุดกับข้อมูลที่คอมโพเนนต์ที่อยู่ห่างกันหลายตัวต้องใช้ร่วมกัน แต่ไม่มีคอมโพเนนต์แม่ตัวไหนที่เป็นเจ้าของข้อมูลนั้นอย่างชัดเจน ตัวอย่างที่เหมาะสม ได้แก่:

  • การตั้งค่า Theme เช่น โหมดสว่างหรือมืด, สีเน้น (accent colors) หรือการปรับขนาดฟอนต์
  • สถานะการยืนยันตัวตน (Authentication state) เช่น ออบเจกต์ผู้ใช้ปัจจุบัน, สถานะการเข้าสู่ระบบ หรือการหมดอายุของ session
  • ภาษาและ Locale สำหรับการทำ internationalization
  • ข้อมูลตะกร้าสินค้า ที่ต้องซิงค์กันระหว่าง badge บน header, dropdown ตะกร้าสินค้าขนาดเล็ก และหน้าชำระเงิน

อย่าเผลอเอา local state ทุกอย่างไปใส่ไว้ใน Context Input ของฟอร์มที่อยู่ลึกลงไปเพียงสองชั้นไม่จำเป็นต้องประกาศให้รู้กันทั้งโลก จงเก็บ Context ไว้สำหรับสิ่งที่เกี่ยวข้องกับหลายส่วนของแอปจริงๆ (cross-cutting concerns) และปล่อยส่วนที่เหลือให้เป็น props ปกติ

กับดักด้านประสิทธิภาพและวิธีหลีกเลี่ยง

Context ไม่ได้มาฟรีๆ เมื่อค่าใน context มีการอัปเดต คอมโพเนนต์ทุกตัวที่เชื่อมต่อกับ context นั้นจะเกิดการ re-render แม้ว่าส่วนของค่าที่พวกมันสนใจจะไม่มีการเปลี่ยนแปลงก็ตาม ข้อผิดพลาดคลาสสิกคือการโยน object literal ใหม่เข้าไปใน Provider ทุกครั้งที่คอมโพเนนต์แม่เกิดการ render

ในตัวอย่าง theme ของเรา ทุกครั้งที่ ThemeProvider ทำการ re-render เพราะ parent ของมันอัปเดต นิพจน์ { theme, setTheme } จะสร้างออบเจกต์ใหม่ขึ้นมาเสมอ React จะเห็นว่าเป็น reference ใหม่ และทำให้ consumer ทุกตัวต้องอัปเดตตาม หาก theme ของคุณแทบจะไม่เปลี่ยนเลย แต่ state ของแอปเปลี่ยนบ่อย คุณกำลังเสียทรัพยากรไปกับการ render ที่ไม่จำเป็น

วิธีแก้ไขมีสองส่วน:

แยก context ตามความถี่ในการอัปเดต UserContext ที่เปลี่ยนเพียงครั้งเดียวตอน login ไม่ควรใช้ provider ร่วมกับ NotificationContext ที่อัปเดตทุกๆ ไม่กี่วินาที จงแยกพวกมันออกจากกันเพื่อให้ข้อมูลที่คงที่ (static data) ไม่ต้องวิ่งไปพร้อมกับรถไฟ re-render ของข้อมูลที่เปลี่ยนแปลงบ่อย (volatile data)

ห่อหุ้มค่าด้วย useMemo เมื่อค่านั้นเป็น object หรือ array เพื่อให้ React ได้รับ reference ที่เสถียร:

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

ตอนนี้ identity ของ object จะเปลี่ยนก็ต่อเมื่อ theme เปลี่ยนแปลงจริง ๆ เท่านั้น ส่วน component ลูกที่ต้องการใช้ context แต่ถูกป้องกันไว้ด้วย React.memo ในระดับที่ลึกลงไปก็จะข้ามการทำงานส่วนนั้นไป

ข้อผิดพลาดที่ทำให้คุณเสียเวลา

ข้อผิดพลาดสองอย่างที่ยังคงปรากฏให้เห็นในโค้ดระดับ production นั้นสามารถป้องกันได้ง่าย

อย่างแรก คือการลืม export ตัว context เอง หากคุณ export เพียงแค่ Provider wrapper และเก็บ object ของ context ไว้เป็นแบบ private นักพัฒนาที่กำลังเขียนฟีเจอร์ใหม่จะไม่สามารถเรียกใช้ useContext ได้โดยไม่ต้องทำการ refactor module ของคุณ ดังนั้นควร export context ออกมา เพื่อให้ผู้ใช้งานสามารถ import ทั้ง provider และ consumer hook ได้อย่างสะดวกและสะอาดตา

อย่างที่สอง คือการเรียกใช้ useContext นอก Provider ที่เกี่ยวข้อง หาก ThemeToggle ถูก render ในกิ่งของ tree ที่ไม่ได้ถูกหุ้มด้วย ThemeProvider ตัว hook จะคืนค่า default ที่ส่งเข้าไปใน createContext หรือคืนค่าเป็น undefined หากคุณไม่ได้ส่งค่าใด ๆ เข้าไป ซึ่งจะนำไปสู่ความผิดพลาดที่ตรวจจับได้ยาก เช่น cannot read property of undefined คุณสามารถป้องกันเรื่องนี้ได้โดยการกำหนดค่า default ที่เหมาะสม หรือการ throw error ที่ชัดเจนตั้งแต่ตอนเรียกใช้ hook

Context vs Redux: เน้นความเรียบง่ายไว้ก่อน

คุณไม่จำเป็นต้องใช้ Redux เสมอไป สำหรับโปรเจกต์ขนาดกลาง การใช้ Context ควบคู่กับ useState หรือ useReducer ก็เพียงพอสำหรับการแชร์ state ส่วนใหญ่แล้ว Redux จะโดดเด่นก็ต่อเมื่อคุณต้องการ time-travel debugging, middleware ที่ซับซ้อน หรือ global transactions ที่ต้องมีการ rollback ตามลำดับ หากสถานะ (state) ทั้งหมดของคุณมีเพียงแค่ user object, theme string และ cart array การใช้ library สำหรับ store จะเป็นการเพิ่ม boilerplate ที่คุณไม่ได้นำมาใช้ประโยชน์เลย

อย่างไรก็ตาม Context ไม่ใช่ระบบจัดการ state ที่สมบูรณ์ในตัวมันเอง มันไม่ได้ให้ global snapshot เพียงหนึ่งเดียว และไม่ได้ทำ batch updates ข้าม context ที่ไม่เกี่ยวข้องกัน จงใช้มันเพื่อแทนที่การทำ prop drilling ไม่ใช่ใช้เป็นระบบปฏิบัติการสำหรับ data layer ทั้งหมดของคุณ

บทสรุปที่สำคัญ

เลิกส่ง props ผ่าน component ที่ไม่ได้จำเป็นต้องใช้มัน สร้าง context ที่เฉพาะเจาะจงสำหรับข้อมูลที่ต้องใช้ครอบคลุมทั้ง tree, หุ้ม provider ไว้ในระดับที่สูงพอที่จะครอบคลุม consumer และรักษาความเสถียร (stabilize) ของ value object เสมอเมื่อคุณต้องส่ง collections หรือ functions ไป Context API จะช่วยให้โค้ด React ของคุณมีความตรงไปตรงมา: props ยังคงอยู่เฉพาะที่ (local), ข้อมูลระดับ global จะถูกส่งผ่านแบบไร้สาย (wirelessly), และขอบเขตของ component ของคุณจะยังคงสะอาดและเป็นระเบียบ