EduFlow

الوحدة 3 · التنقّل والـ State

State مشتركة باستخدام Context والـ reducers

19 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

شارك قائمة الرحلات في Trailhead بين الـ tabs والشاشات باستخدام reducer وcontext يُوفَّران من الـ root layout، واشتقّ المجاميع بدلًا من تخزينها، واعرف متى تستحق مكتبة لإدارة الـ state مكانها.

  1. 1أين تعيش الـ state المشتركة في Expo Router
  2. 2reducer لكل تغيير
  3. 3الـ provider والـ hooks الخاصة به
  4. 4اشتقّ ولا تكرّر
  5. 5حين لا يكفي الـ context

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

أربعة أجزاء من Trailhead تهتم الآن بالبيانات نفسها. tab السجلّ يعرض الرحلات، وtab الإحصاءات يجمعها، وشاشة التفاصيل تعرض واحدة منها، ونموذج إضافة رحلة (في الدرس التالي) ينشئها. إن حمّلت كل شاشة نسختها الخاصة واحتفظت بها، فستتباعد النسخ: تحذف رحلة في شاشة التفاصيل، وتعود، فتجد tab الإحصاءات ما زال يحسبها، لأن شاشات الـ stack والـ tabs التي زرتها تبقى مركّبة ولا تُعيد التحميل.

الحل هو مبدأ React الذي تعرفه أصلًا، مطبّقًا على شجرة تنقّل: مصدر واحد للحقيقة، محفوظ فوق كل ما يحتاج إليه.

أين تعيش الـ state المشتركة في Expo Router

في تطبيق ويب كنت ستضع الـ provider قرب الـ component الجذري. في Expo Router، الـ component الجذري هو src/app/_layout.tsx: فهو يرسم الـ Stack، الذي يرسم مجموعة الـ tabs وكل شاشة تُضاف. والـ provider الموضوع حول ذلك الـ Stack يغطّي التطبيق كله.

يغلّف HikesProvider الـ Stack الجذري في src/app/_layout.tsx؛ ويقرأ tab السجلّ وtab الإحصاءات وشاشة تفاصيل الرحلة الرحلات نفسها ويرسلون actions إلى الـ reducer نفسه. HikesProvider src/app/_layout.tsx الـ Stack الجذري tab السجلّ tab الإحصاءات /hikes/[id] dispatch
provider واحد في الـ root layout يغذّي كل شاشة

reducer لكل تغيير

الرحلات تُضاف وتُعدَّل وتُحذف وتُفضَّل. بدلًا من بعثرة استدعاءات setHikes في الشاشات، صِف كل تغيير بوصفه action وعالجها كلها في دالة نقية واحدة:

src/state/hikesReducer.ts
export type Hike = { id: string; name: string; date: string; distanceKm: number; favorite: boolean };

export type HikesAction =
  | { type: 'added'; hike: Hike }
  | { type: 'updated'; id: string; changes: Partial<Omit<Hike, 'id'>> }
  | { type: 'deleted'; id: string }
  | { type: 'favoriteToggled'; id: string };

export function hikesReducer(state: Hike[], action: HikesAction): Hike[] {
  switch (action.type) {
    case 'added':
      return [action.hike, ...state];
    case 'updated':
      return state.map((h) => (h.id === action.id ? { ...h, ...action.changes } : h));
    case 'deleted':
      return state.filter((h) => h.id !== action.id);
    case 'favoriteToggled':
      return state.map((h) => (h.id === action.id ? { ...h, favorite: !h.favorite } : h));
  }
}

كل حالة تُرجع مصفوفة جديدة، وكائنًا جديدًا للرحلة التي تغيّرت. هكذا تعرف React أن شيئًا تغيّر، وهذا ما يسمح لـ HikeRow المغلّف بـ memo بتخطّي إعادة الـ render للصفوف الـ 599 التي لم تتغيّر.

لاحظ أسماء الـ actions: added وdeleted وfavoriteToggled. إنها تصف ما حدث، لا كيفية تحديث الـ state. وهذا يُبقي الشاشات بسيطة (شاشة التفاصيل تُبلغ أن المستخدم بدّل حالة التفضيل، ولا يهمّها كيف تُخزَّن القائمة)، ويعطيك مكانًا واحدًا تغيّره حين تتغيّر القواعد، كأن يصبح حذف رحلة يعني حذف صورها أيضًا.

ولأن الـ reducer لا يستورد شيئًا من React Native، فهو يعمل في أي مكان. ستختبر reducers كهذا باستخدام Jest العادي في القسم 5، وستكتب واحدًا في تمرين هذا الدرس.

الـ provider والـ hooks الخاصة به

src/state/hikes.tsx
import { createContext, use, useMemo, useReducer, type Dispatch, type ReactNode } from 'react';
import { hikesReducer, type Hike, type HikesAction } from './hikesReducer';

const HikesContext = createContext<Hike[] | null>(null);
const DispatchContext = createContext<Dispatch<HikesAction> | null>(null);

export function HikesProvider({ initial, children }: { initial: Hike[]; children: ReactNode }) {
  const [hikes, dispatch] = useReducer(hikesReducer, initial);
  return (
    <DispatchContext value={dispatch}>
      <HikesContext value={hikes}>{children}</HikesContext>
    </DispatchContext>
  );
}

export function useHikes() {
  const hikes = use(HikesContext);
  if (!hikes) throw new Error('useHikes must be used inside HikesProvider');
  return hikes;
}

export function useHike(id: string | undefined) {
  const hikes = useHikes();
  return useMemo(() => hikes.find((h) => h.id === id), [hikes, id]);
}

export function useHikesDispatch() {
  const dispatch = use(DispatchContext);
  if (!dispatch) throw new Error('useHikesDispatch must be used inside HikesProvider');
  return dispatch;
}

ثم غلّف الـ Stack في الـ root layout: <HikesProvider initial={sampleHikes}><Stack>…</Stack></HikesProvider>. وفي القسم 4 ستأتي البيانات الأولية من قاعدة بيانات الجهاز بدلًا من ذلك.

بعض التفاصيل تستحق النسخ. تسمح لك React 19 برسم الـ context مباشرة بوصفه provider (<HikesContext value={…}>) وقراءته باستخدام use. وفصل الـ state والـ dispatch في اثنين من الـ contexts منفصلَين يعني أن الـ components التي ترسل actions فقط، مثل زرّ حذف، لا يُعاد لها الـ render حين تتغيّر القائمة. والـ hooks ترمي خطأً واضحًا إن نسي أحدٌ الـ provider، بدلًا من الفشل لاحقًا برسالة “cannot read properties of null”.

وهكذا تُقرأ الشاشة الآن:

TSX
const hike = useHike(id);
const dispatch = useHikesDispatch();
// …
<Pressable onPress={() => dispatch({ type: 'favoriteToggled', id: hike.id })}>

اشتقّ ولا تكرّر

يحتاج tab الإحصاءات إلى المسافة الإجمالية، وعدد الرحلات، وأطول رحلة. لا شيء من ذلك state. إنها قيم مشتقّة من قائمة الرحلات، فاحسبها:

TSX
const hikes = useHikes();
const stats = useMemo(() => {
  const totalKm = hikes.reduce((sum, h) => sum + h.distanceKm, 0);
  const longest = hikes.reduce((best, h) => (!best || h.distanceKm > best.distanceKm ? h : best), undefined as Hike | undefined);
  return { count: hikes.length, totalKm, longest };
}, [hikes]);

حين لا يكفي الـ context

يعيد الـ context الـ render لكل مستهلك حين تتغيّر قيمته. لقائمة الرحلات في Trailhead هذا جيد. لكنه يصبح مشكلة حين تتغيّر قيمة ما كثيرًا جدًا (موقع GPS حيّ يتحدّث كل ثانية)، أو حين يكون في تطبيق كبير مستهلكون كثيرون لا علاقة بينهم. عندها تلجأ الفرق إلى Zustand، وهي store صغيرة مع selectors بحيث لا يُعاد الـ render للـ components إلا بسبب الشريحة التي تقرؤها. أما البيانات التي تعيش على خادم فلها مشكلات مختلفة تمامًا (التخزين المؤقت، وإعادة الجلب، وتقادم البيانات)، وهذه وظيفة TanStack Query؛ ستلتقي بها في القسم 4. ابدأ بالـ reducer مع الـ context، وانتقل حين تقيس سببًا لذلك.

التالي: الشاشة التي تنشئ الرحلات، أي نموذج يعمل مع لوحة مفاتيح الهاتف.

أهم النقاط

  • الـ state التي تستخدمها عدّة مسارات مكانها فوقها جميعًا، وهذا في Expo Router يعني provider داخل _layout.tsx الجذري.
  • يجمع الـ reducer كل تغيير على قائمة الرحلات في دالة نقية واحدة قابلة للاختبار تُرجع مصفوفات وكائنات جديدة.
  • غلّف الـ context بـ hooks صغيرة مثل useHikes() وuseHike(id)، حتى لا تلمس الشاشات كائن الـ context مباشرة أبدًا.
  • اشتقّ المجاميع والقوائم المصفّاة من الـ state المصدر باستخدام useMemo؛ ولا تخزّنها منفصلة.
  • استخدم مكتبة مثل Zustand حين تصبح عمليات re-render الناتجة عن الـ context مشكلة مقيسة، وTanStack Query حين تعيش البيانات على خادم.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

يخزّن tab الإحصاءات totalKm في useState خاصة به، تُحدَّث داخل effect. بعد أن يحذف المستخدم رحلة، يبقى المجموع خاطئًا حتى يُعاد تشغيل التطبيق. ما أفضل إصلاح؟

السؤال 2 من 3

ما المشكلة في حالة الـ reducer هذه؟

JavaScript
case 'favoriteToggled': {
  const hike = state.find((h) => h.id === action.id);
  hike.favorite = !hike.favorite;
  return state;
}
السؤال 3 من 3

أين يجب أن يُرسم HikesProvider الخاص بـ Trailhead لكي يقرأه tab السجلّ وtab الإحصاءات وشاشة /hikes/[id] جميعًا؟

تدريب

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

اجعل reducer الرحلات غير قابل للتعديل المباشر

يعدّل hikesReducer في Trailhead الـ state مباشرة، فلا تتحدّث الشاشات. أعد كتابته بحيث تُرجع كل حالة مصفوفات وكائنات جديدة ولا تغيّر state أبدًا. أبقِ القائمة مرتّبة من الأحدث أولًا بحسب date (نصوص بصيغة YYYY-MM-DD).

  • added: أدرج action.hike بترتيب التاريخ. إن كانت رحلة بذلك الـ id موجودة أصلًا، فأرجع state نفسها.
  • updated: ادمج action.changes في الرحلة ذات action.id، وأعد الترتيب، ولا تسمح أبدًا لـ changes بالكتابة فوق الـ id.
  • deleted: احذف الرحلة.
  • favoriteToggled: اقلب قيمة favorite؛ والرحلات التي لم تتغيّر تحتفظ بالكائن نفسه، لكي تتخطّى الصفوف المغلّفة بـ memo إعادة الـ render.
  • أي action غير معروف يُرجع state نفسها (لا ترمِ خطأ).
// Trailhead keeps hikes sorted newest first (dates are 'YYYY-MM-DD').
function hikesReducer(state, action) {
  switch (action.type) {
    case 'added':
      state.push(action.hike);
      return state;
    case 'updated': {
      const hike = state.find((h) => h.id === action.id);
      Object.assign(hike, action.changes);
      return state;
    }
    case 'deleted':
      return state.filter((h) => h.id !== action.id);
    case 'favoriteToggled': {
      const hike = state.find((h) => h.id === action.id);
      hike.favorite = !hike.favorite;
      return state;
    }
    default:
      throw new Error('Unknown action ' + action.type);
  }
}

const start = [
  { id: 'a', name: 'Lake Trail', date: '2026-10-19', distanceKm: 11.2, favorite: false },
  { id: 'b', name: 'Ridge Loop', date: '2026-10-04', distanceKm: 8.4, favorite: true },
];
console.log(hikesReducer(start, { type: 'added', hike: { id: 'c', name: 'Fern Gully', date: '2026-10-10', distanceKm: 3.6, favorite: false } }).map((h) => h.id));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الرحلات المضافة تقع بترتيب التاريخ في مصفوفة جديدة
  • إضافة معرّف موجود تُرجع الـ state نفسها
  • التحديث يدمج ويعيد الترتيب ويحتفظ بالمعرّف ولا يعدّل مباشرة
  • تبديل التفضيل ينسخ الرحلة التي تغيّرت فقط
  • الحذف يُرجع مصفوفة جديدة أقصر
  • الـ actions غير المعروفة تُرجع الـ state نفسها دون رمي خطأ