EduFlow
أساسيات React
⁦0%⁩

الوحدة 3 · الـ State والأحداث

رفع الـ state للأعلى

18 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

شارك الـ state بين components إخوة بنقله إلى أقرب أب مشترك لها، وتمرير القيم إلى الأسفل ومعالجات التغيير إلى الأعلى، ليبقى للتطبيق مصدر واحد للحقيقة.

  1. 1المشكلة: الإخوة لا يتشاركون
  2. 2الحلّ: ارفعه إلى الأب المشترك
  3. 3البيانات تنزل، والأحداث تصعد
  4. 4علامات أنك تحتاج إلى الرفع
  5. 5في أدنى مكان ممكن، وأعلى مكان ضروري

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

لديك الآن نموذج لإضافة الأفلام، وقائمة أفلام، وأزرار فلترة. كل منها يعمل وحده. ضعها على شاشة واحدة ولن يتحدّث بعضها مع بعض: لا يستطيع النموذج الإضافة إلى قائمة تعيش في component آخر، ولا تعرف القائمة أي زر فلترة مضغوط. لا تستطيع الـ components أن تمدّ يدها إلى الـ state الخاص ببعضها. هذا مقصود، والحلّ نمط ستستخدمه في كل تطبيق React تبنيه.

المشكلة: الإخوة لا يتشاركون

هذا شريط الفلترة كما قد تكتبه أول مرة:

JSX
function FilterBar() {
  const [filter, setFilter] = useState('all');
  return (
    <div>
      <button aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>All</button>
      <button aria-pressed={filter === 'to-watch'} onClick={() => setFilter('to-watch')}>To watch</button>
      <button aria-pressed={filter === 'watched'} onClick={() => setFilter('watched')}>Watched</button>
    </div>
  );
}

يظهر الزر النشط مميّزًا بشكل صحيح، لكن MovieList أخ له. لا يصل إلى filter، فيعرض كل شيء مهما نقرت. الـ state خاص بالـ component الذي يعلن عنه.

الحلّ: ارفعه إلى الأب المشترك

اعثر على أقرب component يكون أبًا لكل ما يحتاج إلى الـ state. بالنسبة إلى الفلتر، هو App، أب كلٍّ من FilterBar وMovieList. انقل الـ state إليه، ثم:

  1. مرّر القيمة إلى كل من يقرؤها، و
  2. مرّر دالة إلى كل من يغيّرها.
src/App.jsx
export default function App() {
  const [movies, setMovies] = useState(INITIAL_MOVIES);
  const [filter, setFilter] = useState('all');

  const visible = movies.filter((m) =>
    filter === 'all' ? true : filter === 'watched' ? m.watched : !m.watched,
  );

  function handleAdd(title) {
    setMovies([...movies, { id: crypto.randomUUID(), title, watched: false }]);
  }

  return (
    <main>
      <Header movies={movies} />
      <AddMovieForm onAdd={handleAdd} />
      <FilterBar value={filter} onChange={setFilter} />
      <MovieList movies={visible} />
    </main>
  );
}

function FilterBar({ value, onChange }) {
  return (
    <div role="group" aria-label="Filter movies">
      <button aria-pressed={value === 'all'} onClick={() => onChange('all')}>All</button>
      <button aria-pressed={value === 'to-watch'} onClick={() => onChange('to-watch')}>To watch</button>
      <button aria-pressed={value === 'watched'} onClick={() => onChange('watched')}>Watched</button>
    </div>
  );
}

لم يعد لدى FilterBar أي state. إنه يعرض value ويبلّغ عن النقرات عبر onChange. ويُسمّى component كهذا controlled (متحكَّمًا به): أبوه يقرّر ما يعرضه. وصار الآن أسهل في الاختبار وإعادة الاستخدام، لأن كل ما يفعله ظاهر في الـ props الخاصة به.

يملك App الـ state المسمّى movies والمسمّى filter. يمرّر movies إلى Header، وonAdd إلى AddMovieForm، وvalue وonChange إلى FilterBar، والأفلام الظاهرة إلى MovieList. ويستدعي AddMovieForm وFilterBar الـ callbacks الخاصة بهما لإرسال التغييرات صعودًا إلى App. App movies · filter Header AddMovieForm FilterBar MovieList الـ props تنزل المتقطّع: استدعاءات onAdd / onChange تصعد
يعيش الـ state في App؛ الـ props تنزل والأحداث تصعد

البيانات تنزل، والأحداث تصعد

لاحظ الشكل. البيانات تتدفّق نزولًا في الشجرة كـ props: movies وfilter وvisible. والتغييرات تتدفّق صعودًا كاستدعاءات دوال: النموذج يستدعي onAdd، وشريط الفلترة يستدعي onChange. ولا يستدعي setMovies أو setFilter إلا App.

هذه هي الثمرة. إذا عرضت القائمة يومًا أفلامًا خاطئة، فهناك مكان واحد بالضبط يقرّر ما فيها. تتبع الـ props صعودًا إلى مالكها وتقرأ الكود الذي يغيّرها. قارن ذلك بتطبيق يستطيع فيه أي component أن يعبث بأي component آخر: قد يأتي الخطأ من أي مكان.

وانظر أيضًا إلى ما ليس state. تُحسب visible من movies وfilter في كل render، ويشتقّ Header العدد من movies. رفع الـ state لا يعني رفع كل شيء؛ بل يعني مالكًا واحدًا لكل قطعة state حقيقية، وكل ما عداها مشتقّ منها.

علامات أنك تحتاج إلى الرفع

نادرًا ما تخطّط لكل قطعة state بشكل مثالي من البداية. تكتشف الحاجة إلى الرفع أثناء البناء، والعلامات ثابتة:

  • componentان يعرضان قيمتين يجب أن تتطابقا لكنهما لا تتطابقان أحيانًا.
  • أنت على وشك كتابة effect وظيفته الوحيدة نسخ state component إلى آخر.
  • ابنٌ يحتاج إلى تغيير شيء يعرضه أخوه.

كل واحدة من هذه تعني أن للبيانات نفسها مالكين، أو أن المالك منخفض جدًا في الشجرة. والرفع إعادة هيكلة صغيرة وآلية: اقطع سطر useState، والصقه في الأب، واستبدل الـ state القديم في الابن بـ props.

في أدنى مكان ممكن، وأعلى مكان ضروري

للرفع ثمن: المالك يعيد الـ render حين يتغيّر الـ state، ومعه أبناؤه، والـ props تضطر إلى قطع مسافة أطول. فلا ترفع كل شيء إلى App بحكم العادة.

النص الذي يُكتب في حقل العنوان مثال جيد. لا يهتمّ به إلا AddMovieForm أثناء الكتابة. أمّا App فلا يحتاج إلا إلى العنوان النهائي، الذي يصله من onAdd. لذا تبقى المسوّدة في النموذج. وإذا احتاج component آخر لاحقًا إلى معاينة المسوّدة، فسترفعها حينها.

وحين يحتاج إلى الـ state components متباعدة في الشجرة، يصبح تمريره عبر كل مستوى مملًّا. رأيت حلًّا في درس التركيب (تمرير العناصر إلى الأسفل)، والـ context في React هو الحل الآخر. أمّا Watchlist فتكفيه بضعة props.

صار لـ Watchlist مصدر واحد للحقيقة وثلاثة components تقرؤه وتغيّره. في الدرس التالي تزامن هذا الـ state مع شيء خارج React، أي عنوان تبويب المتصفّح وlocalStorage، باستخدام الـ effects.

أهم النقاط

  • حين يحتاج componentان إلى البيانات المتغيّرة نفسها، انقل الـ state إلى أقرب أب مشترك لهما ومرّره إلى الأسفل كـ props.
  • يغيّر الأبناء الـ state المشترك باستدعاء props من نوع المعالجات مثل onAdd أو onFilterChange؛ البيانات تنزل والأحداث تصعد.
  • أبقِ كل قطعة state في أدنى مكان ممكن من الشجرة، لكن في أعلى مكان ضروري.
  • لا تنسخ prop إلى state باستخدام useState(prop)؛ فالنسخة تتجاهل التغييرات اللاحقة. استخدم الـ prop مباشرة أو اشتقّ منه.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يحتفظ FilterBar بـ const [filter, setFilter] = useState('all'). ويحتاج أخوه MovieList إلى عرض الأفلام المطابقة للفلتر فقط. ما الإصلاح؟

السؤال 2 من 4

أيّ قطعة من state في Watchlist يجب أن تبقى داخل AddMovieForm بدل رفعها إلى App؟

السؤال 3 من 4

يعرض MovieTitle عنوانًا يستقبله كـ prop. وحين يغيّر الأب اسم الفيلم، يظل الابن يعرض العنوان القديم. لماذا؟

JSX
function MovieTitle({ title }) {
  const [text] = useState(title);
  return <h2>{text}</h2>;
}
السؤال 4 من 4

في Watchlist تنزل البيانات كـ props وتصعد التغييرات عبر الـ callbacks. ما الفائدة الأساسية لهذا التدفّق باتجاه واحد؟

تدريب

بيئة تجريب الكود

ارفع الفلتر

يظهر زر الفلترة النشط مميّزًا بشكل صحيح، لكن القائمة لا تتغيّر أبدًا، لأن FilterBar يحتفظ بالفلتر لنفسه.

ارفع state الفلتر إلى App. مرّر القيمة الحالية وطريقة لتغييرها إلى FilterBar (يجب ألّا يكون له state خاص به)، وأعطِ MovieList الأفلام المطابقة فقط:

  • All: كل الأفلام
  • To watch: قيمة watched هي false
  • Watched: قيمة watched هي true

أبقِ aria-pressed على الأزرار تعمل.

import { useState } from 'react';

const MOVIES = [
  { id: 'a1', title: 'Arrival', watched: true },
  { id: 'p2', title: 'Past Lives', watched: false },
  { id: 'd3', title: 'Dune: Part Two', watched: false },
];

function MovieList({ movies }) {
  return (
    <ul>
      {movies.map((m) => <li key={m.id}>{m.title}</li>)}
    </ul>
  );
}

function FilterBar() {
  const [filter, setFilter] = useState('all');
  return (
    <div>
      <button aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>All</button>
      <button aria-pressed={filter === 'to-watch'} onClick={() => setFilter('to-watch')}>To watch</button>
      <button aria-pressed={filter === 'watched'} onClick={() => setFilter('watched')}>Watched</button>
    </div>
  );
}

export default function App() {
  // MovieList should only show movies that match the selected filter.
  return (
    <main>
      <FilterBar />
      <MovieList movies={MOVIES} />
    </main>
  );
}
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

المعاينة

الـ console

(لا مخرجات)

الفحوص

  • يبدأ على All ويعرض الأفلام الثلاثة
  • يعرض To watch الفيلمين Past Lives وDune: Part Two، ويكون مضغوطًا
  • يعرض Watched فيلم Arrival وحده، ويكون مضغوطًا
  • يعرض All كل الأفلام مجددًا