EduFlow

الوحدة 4 · البيانات والجهاز

جلب البيانات: حالات التحميل والخطأ والفراغ

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

الدرس في لمحة

حمّل كتالوج الدروب في Trailhead من خادم باستخدام fetch، وصمّم حالات التحميل والخطأ والفراغ والنجاح صراحةً، وألغِ الطلبات القديمة، وتعامل مع الشبكات المتقلّبة التي تعيش عليها الهواتف.

  1. 1أربع حالات، وقيمة واحدة
  2. 2الجلب مع التنظيف
  3. 3السحب للتحديث والشبكات البطيئة
  4. 4غياب الاتصال حالة طبيعية، لا خطأ
  5. 5متى تستخدم مكتبة للبيانات

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

حتى الآن، عاشت بيانات Trailhead على الهاتف. أما الميزة التالية، كتالوج الدروب القريبة منك، فتأتي من خادم. على حاسوب محمول متصل بشبكة Wi-Fi المكتب، الطلب مجرّد إجراء شكلي. أما على هاتف عند بداية درب وفيه خطّ إشارة واحد، فهو أبطأ ما يفعله تطبيقك وأقلّه موثوقية: تستغرق الطلبات ثواني، أو تفشل في منتصفها، أو لا تعود أبدًا. والتصميم لهذا الواقع هو معظم العمل.

أربع حالات، وقيمة واحدة

كل شاشة تحمّل بيانات لها أربع حالات على الأقل يمكن أن يراها المستخدم: التحميل (loading)، والخطأ (error)، والفراغ (empty: اكتمل التحميل ولا يوجد شيء)، والنجاح (success). مثّلها بقيمة واحدة، فلا يمكن أن تحدث تركيبات مستحيلة:

src/lib/types.ts
export type Trail = { id: string; name: string; lengthKm: number; difficulty: 'easy' | 'moderate' | 'hard' };

export type Load<T> =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: T };

الفراغ ليس حالة منفصلة؛ إنه نجاح بصفر عناصر، والشاشة تقرّر كيف تعرضه.

تبدأ الشاشة في حالة loading. الاستجابة الناجحة تنقلها إلى success، التي تعرض القائمة أو حالة الفراغ. والفشل ينقلها إلى error، وزرّ إعادة المحاولة يعيدها إلى loading. والسحب للتحديث ينقلها من success إلى loading من جديد. loading success القائمة أو حالة الفراغ error رسالة + إعادة المحاولة استجابة ok فشل إعادة تحديث
الطلب ينتقل بين حالات صريحة

الجلب مع التنظيف

src/app/(tabs)/explore.tsx
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native';
import type { Load, Trail } from '../../lib/types';

const API = process.env.EXPO_PUBLIC_API_URL;

async function getTrails(signal: AbortSignal): Promise<Trail[]> {
  const res = await fetch(`${API}/trails?near=current`, { signal });
  if (!res.ok) throw new Error(`Server responded ${res.status}`);
  return res.json();
}

export default function Explore() {
  const [state, setState] = useState<Load<Trail[]>>({ status: 'loading' });
  const [attempt, setAttempt] = useState(0);
  const retry = useCallback(() => setAttempt((n) => n + 1), []);

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading' });
    getTrails(controller.signal)
      .then((data) => setState({ status: 'success', data }))
      .catch(() => {
        if (controller.signal.aborted) return;
        setState({ status: 'error', message: 'Could not load trails. Check your connection and try again.' });
      });
    return () => controller.abort();
  }, [attempt]);

  if (state.status === 'loading') return <ActivityIndicator style={{ flex: 1 }} size="large" />;

  if (state.status === 'error') {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, padding: 24 }}>
        <Text style={{ textAlign: 'center' }}>{state.message}</Text>
        <Pressable onPress={retry} accessibilityRole="button"><Text>Try again</Text></Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={state.data}
      keyExtractor={(t) => t.id}
      renderItem={({ item }) => <Text style={{ padding: 16 }}>{item.name} · {item.lengthKm} km</Text>}
      ListEmptyComponent={<Text style={{ padding: 24 }}>No trails within 50 km. Try a wider search.</Text>}
    />
  );
}

ثلاثة تفاصيل تحمل الثقل. يُفحص res.ok يدويًا، لأن fetch يعامل الـ 500 على أنه توصيل ناجح لأخبار سيئة. ويلغي الـ AbortController الطلب في دالة التنظيف، فمغادرة الشاشة (أو إعادة المحاولة) لا تسمح أبدًا لاستجابة قديمة بالكتابة فوق استجابة حديثة. ورسالة الخطأ تخبر المستخدم بما يفعل، بدلًا من طباعة TypeError: Network request failed.

يأتي EXPO_PUBLIC_API_URL من ملف .env. المتغيّرات التي تبدأ بـ EXPO_PUBLIC_ تُدرج مباشرة في حزمة JavaScript، وهذا يعني أيضًا أنها قابلة للقراءة من أي أحد يفكّ حزمة تطبيقك. ضع فيها العناوين، ولا تضع المفاتيح السرّية أبدًا.

السحب للتحديث والشبكات البطيئة

يتوقّع المستخدمون أن يسحبوا القائمة إلى الأسفل لإعادة تحميلها. يدعم FlatList ذلك بخاصّيتين:

TSX
<FlatList
  // …
  refreshing={refreshing}
  onRefresh={async () => {
    setRefreshing(true);
    try { setState({ status: 'success', data: await getTrails(new AbortController().signal) }); }
    catch { /* keep the old list and show a short "Couldn't refresh" message */ }
    finally { setRefreshing(false); }
  }}
/>

يُبقي التحديث القائمة القديمة ظاهرة أثناء التحميل، وهذا ألطف من مؤشّر التحميل بملء الشاشة في التحميل الأول. وإن فشل التحديث، فاحتفظ بالبيانات القديمة واعرض رسالة قصيرة، بدلًا من استبدال قائمة سليمة تمامًا بشاشة خطأ.

والشبكات المتقلّبة حجّة أيضًا لصالح إعادة المحاولة التلقائية مع تأخير يتزايد بين المحاولات (ثانية، ثم ثانيتان، ثم أربع)، فيتعافى التطبيق وحده من انقطاع قصير في الإشارة. لا تُعِد المحاولة إلا لما يمكن أن ينجح لاحقًا: انتهاء المهلة أو 503 قد ينجحان، أما 404 أو 401 فلن ينجحا، لذا فالفشل السريع ألطف. ستكتب هذه الدالة المساعدة في التمرين.

غياب الاتصال حالة طبيعية، لا خطأ

في تطبيق للمشي الجبلي، غياب الإشارة هو الوضع المتوقّع لساعات متواصلة. تعامل معه على هذا الأساس. اعرض آخر قائمة حمّلتها، مع شريط هادئ يذكر وقت آخر تحديث، بدلًا من شاشة خطأ. ودع المستخدمين يواصلون تسجيل الرحلات، واحفظها على الجهاز، وأرسلها حين يعود الاتصال. تخبرك حزمة المجتمع @react-native-community/netinfo هل الجهاز متصل، وتُعلمك حين يتغيّر ذلك، وتلك هي اللحظة لإعادة محاولة الأعمال المؤجّلة.

لا شيء من ذلك يحتاج إلى أن يكون ذكيًا من اليوم الأول. المهم هو القرار: لكل شاشة، ماذا يرى المستخدم دون شبكة؟ اكتب الإجابة قبل أن تكتب الـ fetch، وصمّم حالات الفراغ والخطأ وغياب الاتصال بالعناية نفسها التي تصمّم بها المسار السعيد.

متى تستخدم مكتبة للبيانات

الكود أعلاه جيد لشاشة واحدة. أما في تطبيق حقيقي فستكرّره في كل مكان، وسيبقى ينقصك التخزين المؤقت (عُد إلى شاشة فترى البيانات فورًا)، ومنع تكرار الطلبات، وإعادة الجلب في الخلفية، وإعادة المحاولة. تتولّى TanStack Query كل ذلك عبر useQuery، وتتضمّن وثائقها الإعداد الصغير الخاص بـ React Native الذي يعيد الجلب حين يعود التطبيق إلى الواجهة. تعلّم النسخة اليدوية أولًا لتعرف ما تفعله المكتبة، ثم استخدم المكتبة في بيئة الإنتاج.

التالي: بيانات يجب أن تصمد دون أي شبكة على الإطلاق، مخزّنة على الجهاز.

أهم النقاط

  • مثّل الطلب بقيمة حالة واحدة (loading أو error أو success) بدلًا من عدّة قيم منطقية قد يناقض بعضها بعضًا.
  • fetch لا يرفض (reject) إلا عند فشل الشبكة؛ تحقّق من response.ok بنفسك لاستجابات 404 و500.
  • ألغِ الطلبات الجارية في دالة التنظيف الخاصة بالـ effect، لأن المستخدمين يغادرون الشاشات قبل أن تنتهي الطلبات البطيئة.
  • كل قائمة تحمّل بيانات تحتاج إلى أربع حالات مصمّمة، منها حالة فراغ وحالة خطأ مع زرّ لإعادة المحاولة.
  • على الهاتف، localhost هو الهاتف نفسه؛ وجّه الـ development builds إلى عنوان حاسوبك على الشبكة أو إلى API منشور.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

يُرجع الـ API الخاص بـ Trailhead الاستجابة 404 Not Found لدرب محذوف. ماذا يفعل await fetch(url)؟

السؤال 2 من 3

يفتح مستخدم شاشة تفاصيل درب ثم يعود فورًا. ينتهي الطلب بعد ثانيتين ويستدعي setState. ما الذي كان يجب أن يفعله الـ effect؟

السؤال 3 من 3

أيّ شكل للـ state يناسب شاشة الكتالوج في Trailhead أكثر؟

تدريب

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

أعِد محاولة الطلبات المتعثّرة مع تأخير متزايد

اكتب withRetry(task, { retries, baseDelayMs, sleep }) لاستدعاءات الـ API في Trailhead على إشارة الجبال الضعيفة.

  • استدعِ task(attempt)، مع attempt تبدأ من 1، وأرجع نتيجتها.
  • إن رفضت، فأعِد المحاولة حتى retries مرة إضافية. قبل كل إعادة، نفّذ await sleep(ms) حيث يتضاعف التأخير: baseDelayMs، ثم 2 × baseDelayMs، ثم 4 ×… (للقيمة 100: 100، 200، 400).
  • الأخطاء التي فيها status رقمية من 400 إلى 499 لا يمكن أن تنجح بإعادة المحاولة: أعد رميها فورًا. والاستثناءان هما 408 (انتهاء المهلة) و429 (طلبات كثيرة جدًا)، فتُعاد محاولتهما.
  • الأخطاء التي ليس فيها status (أعطال الشبكة) وأخطاء 5xx تُعاد محاولتها.
  • حين تفشل كل المحاولات، ارفض بـ آخر خطأ.

انتظر دائمًا باستخدام الـ sleep المعطاة لك، لكي تستطيع الفحوص تسجيل التأخيرات.

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Run task(attempt). Retry failures with a doubling delay; give up fast on errors
// that can't succeed later. `sleep` is injectable so tests don't really wait.
async function withRetry(task, { retries = 2, baseDelayMs = 1000, sleep = wait } = {}) {
  return task(1);
}

// Demo: fails twice with a 503, then succeeds.
let calls = 0;
const flaky = async () => {
  calls += 1;
  if (calls < 3) throw Object.assign(new Error('Service unavailable'), { status: 503 });
  return ['Ridge Loop', 'Lake Trail'];
};
withRetry(flaky, { baseDelayMs: 10 }).then((v) => console.log('got', v, 'after', calls, 'calls'), (e) => console.log('failed:', e.message));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • المهمّة الناجحة تُرجع قيمتها دون انتظار
  • المهمّة التي تفشل مرتين ثم تنجح تُرجع قيمتها
  • تتضاعف التأخيرات بين المحاولات (100، 200، 400)
  • بعد المحاولة الأخيرة، يُرمى آخر خطأ
  • يُرمى الـ 404 فورًا دون إعادة محاولة
  • تُعاد محاولة استجابات 429 و408