الوحدة 3 · التنقّل والـ State
State مشتركة باستخدام Context والـ reducers
19 دقراءة 3 دبيئة تجريب الكود
الدرس في لمحة
شارك قائمة الرحلات في Trailhead بين الـ tabs والشاشات باستخدام reducer وcontext يُوفَّران من الـ root layout، واشتقّ المجاميع بدلًا من تخزينها، واعرف متى تستحق مكتبة لإدارة الـ state مكانها.
- 1أين تعيش الـ state المشتركة في Expo Router
- 2reducer لكل تغيير
- 3الـ provider والـ hooks الخاصة به
- 4اشتقّ ولا تكرّر
- 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 يغطّي التطبيق كله.
reducer لكل تغيير
الرحلات تُضاف وتُعدَّل وتُحذف وتُفضَّل. بدلًا من بعثرة استدعاءات setHikes في الشاشات، صِف كل تغيير بوصفه action وعالجها كلها في دالة نقية واحدة:
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 الخاصة به
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”.
وهكذا تُقرأ الشاشة الآن:
const hike = useHike(id);
const dispatch = useHikesDispatch();
// …
<Pressable onPress={() => dispatch({ type: 'favoriteToggled', id: hike.id })}>اشتقّ ولا تكرّر
يحتاج tab الإحصاءات إلى المسافة الإجمالية، وعدد الرحلات، وأطول رحلة. لا شيء من ذلك state. إنها قيم مشتقّة من قائمة الرحلات، فاحسبها:
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 حين تعيش البيانات على خادم.