عندما أصبح GraphQL وخاصة Apollo Client شائعًا في عام 2018، كان هناك الكثير من الضجة حول استبداله الكامل لـ Redux، وتم طرح السؤال كثيراً هل Redux انتهى؟
أتذكر جيداً أنني لم أفهم ما كان يدور حوله كل هذا. كيف يمكن لمكتبة جلب البيانات أن تحل محل state manager العام الخاص بك؟ ما علاقة أحدهما بالآخر؟
كنت أعتقد أن clients GraphQL مثل Apollo سيقومون فقط بجلب البيانات نيابة عنك، بشكل مشابه لما يقوم به axios مع REST، وأنك ستظل بحاجة إلى طريقة لجعل هذه البيانات متاحة لتطبيقك.
لم أكن أكثر خطأً من ذلك.
Client State مقابل Server State#
ما يقدمه Apollo ليس فقط القدرة على وصف البيانات التي تريدها وجلبها، بل يأتي أيضًا مع cache للبيانات. هذا يعني أنه يمكنك استخدام نفس الـ hook المسمى useQuery في مكونات متعددة، وسيقوم بجلب البيانات مرة واحدة فقط ثم يقوم باسترجاعها من الـ cache.
هذا يبدو مألوفًا جداً مع ما كنا نستخدمه، وربما العديد من الفرق الأخرى أيضاً، حيث كنا نستخدم Redux بشكل أساسي لجلب البيانات من الخادم وجعلها متاحة في كل مكان.
يبدو أننا كنا دائماً نتعامل مع server state مثل أي client state آخر. ولكن عندما يتعلق الأمر بـ server state (فكر في: قائمة المقالات التي تجلبها، أو تفاصيل المستخدم التي تريد عرضها)، تطبيقك لا يمتلكها. لقد استعرنا هذه البيانات فقط لعرض أحدث إصدار منها على الشاشة للمستخدم. الخادم هو من يمتلك البيانات.
بالنسبة لي، هذا أحدث تحولاً في كيفية التفكير في البيانات. إذا كان بإمكاننا الاستفادة من الـ cache لعرض البيانات التي لا نمتلكها، فلن يتبقى الكثير من client state الحقيقي الذي يحتاج أيضًا إلى أن يكون متاحًا للتطبيق بأكمله. هذا جعلني أفهم لماذا يعتقد الكثيرون أن Apollo يمكن أن يحل محل Redux في حالات كثيرة.
React Query#
لم تتح لي الفرصة أبداً لاستخدام GraphQL. لدينا REST API موجود بالفعل، ولا نواجه حقاً مشاكل مع over-fetching، إنه يعمل بشكل جيد. من الواضح أنه لا توجد نقاط كافية تبرر لنا التحول، خاصة مع الحاجة إلى تعديل الـ backend، وهو أمر ليس بالبسيط.
ومع ذلك، كنت أحسد بساطة كيفية جلب البيانات في الـ frontend، بما في ذلك التعامل مع حالات التحميل والأخطاء. لو كان هناك شيء مماثل في React للتعامل مع REST APIs...
وهنا يأتي دور React Query.
تم تطويره بواسطة مطور open source Tanner Linsley في أواخر 2019، حيث يأخذ React Query الأجزاء الجيدة من Apollo ويجلبها إلى REST. يعمل مع أي function يقوم بإرجاع Promise ويتبنى استراتيجية stale-while-revalidate caching. تعمل المكتبة بإعدادات افتراضية ذكية تحاول الحفاظ على بياناتك محدثة قدر الإمكان مع عرض البيانات للمستخدم في أسرع وقت ممكن، مما يجعلها تبدو فورية في بعض الأحيان وبالتالي توفر تجربة مستخدم رائعة. بالإضافة إلى ذلك، فهي مرنة للغاية وتتيح لك تخصيص الإعدادات المختلفة عندما لا تكون الإعدادات الافتراضية كافية.
هذا المقال لن يكون مقدمة لـ React Query.
أعتقد أن التوثيق ممتاز في شرح المفاهيم والإرشادات، وهناك فيديوهات من محادثات مختلفة يمكنك مشاهدتها، كما قام Tanner بإعداد دورة أساسية لـ React Query يمكنك أخذها إذا أردت التعرف على المكتبة.
أريد التركيز أكثر على بعض النصائح العملية التي تتجاوز التوثيق، والتي قد تكون مفيدة عندما تعمل بالفعل مع المكتبة. هذه الأشياء التي اكتسبتها خلال الأشهر القليلة الماضية عندما كنت أستخدم المكتبة بنشاط في العمل، وأيضاً شاركت في مجتمع React Query، من خلال الإجابة على الأسئلة في Discord و GitHub Discussions.
شرح الإعدادات الافتراضية#
أعتقد أن الإعدادات الافتراضية لـ React Query تم اختيارها بشكل جيد، لكنها قد تفاجئك من وقت لآخر، خاصة في البداية.
أولاً: React Query لا يقوم بتنفيذ queryFn مع كل عملية re-render، حتى مع القيمة الافتراضية staleTime التي تساوي صفر. يمكن أن يحدث re-render لتطبيقك لأسباب مختلفة في أي وقت، لذا فإن جلب البيانات في كل مرة سيكون أمراً غير منطقي!
دائماً قم بالبرمجة مع مراعاة عمليات الـ re-renders، والكثير منها. أحب أن أسميها مرونة العرض.
إذا رأيت عملية refetch غير متوقعة، فمن المحتمل أن ذلك بسبب تركيزك على النافذة وقيام React Query بتنفيذ refetchOnWindowFocus، وهي ميزة رائعة للإنتاج: إذا انتقل المستخدم إلى علامة تبويب مختلفة في المتصفح، ثم عاد إلى تطبيقك، سيتم تشغيل refetch في الخلفية تلقائياً، وسيتم تحديث البيانات على الشاشة إذا تغير شيء ما على الخادم في هذه الأثناء. كل هذا يحدث بدون عرض مؤشر التحميل، ولن يتم إعادة عرض المكون إذا كانت البيانات هي نفسها الموجودة حالياً في الـ cache.
أثناء التطوير، من المحتمل أن يتم تشغيل هذا بشكل متكرر، خاصة لأن التركيز بين Browser DevTools وتطبيقك سيؤدي أيضاً إلى عملية fetch، لذا كن على دراية بذلك.
منذ الإصدار الخامس من React Query، لم يعد refetchOnWindowFocus يستمع إلى حدث focus - يتم استخدام حدث visibilitychange حصرياً. هذا يعني أنك ستحصل على عمليات re-fetch غير مرغوب فيها أقل في وضع التطوير، مع الاحتفاظ بالتشغيل التلقائي في معظم حالات الإنتاج. كما أنه يصلح مجموعة من المشكلات كما هو موضح هنا.
ثانياً، يبدو أن هناك بعض الالتباس بين gcTime و staleTime، لذا دعني أوضح ذلك:
-
staleTime: المدة حتى تنتقل query من حالة fresh إلى stale. طالما أن الـ query في حالة fresh، سيتم قراءة البيانات من الـ cache فقط - لن يحدث أي طلب شبكة! إذا كانت الـ query في حالة stale (وهو الوضع الافتراضي: فوري)، ستظل تحصل على البيانات من الـ cache، ولكن يمكن أن يحدث refetch في الخلفية في ظروف معينة.
-
gcTime: المدة حتى تتم إزالة الـ queries غير النشطة من الـ cache. القيمة الافتراضية هي 5 دقائق. تنتقل الـ queries إلى حالة غير نشطة بمجرد عدم تسجيل أي observers، أي عندما يتم إزالة جميع المكونات التي تستخدم تلك الـ query. في معظم الأحيان، إذا كنت تريد تغيير أحد هذه الإعدادات، فإن staleTime هو ما يحتاج إلى التعديل. نادراً ما احتجت إلى العبث بـ gcTime. هناك شرح جيد بالأمثلة في التوثيق أيضاً.
كان gcTime يُعرف سابقاً باسم cacheTime، لكن تم إعادة تسميته في الإصدار الخامس ليعكس بشكل أفضل ما يقوم به.
استخدام React Query DevTools#
سيساعدك هذا بشكل كبير في فهم حالة الـ query. ستخبرك الـ DevTools أيضاً عن البيانات الموجودة حالياً في الـ cache، مما يسهل عليك عملية التصحيح. بالإضافة إلى ذلك، وجدت أنه من المفيد إبطاء اتصال الشبكة في Browser DevTools إذا كنت تريد التعرف بشكل أفضل على عمليات refetch في الخلفية، نظراً لأن خوادم التطوير عادةً ما تكون سريعة جداً.
تعامل الـ query key كمصفوفة تبعية#
أنا أشير إلى dependency array لـ useEffect hook هنا، والتي أفترض أنك تعرفها.
لماذا هذان متشابهان؟
لأن React Query ستقوم بتنفيذ refetch عندما تتغير مفتاح الـ query. إذا عبرنا معامل متغير إلى queryFn، فنحن عادة ما نريد جلب البيانات عندما تتغير قيمة هذا المعامل. بدلاً من تنظيم تأثيرات معقدة لتنفيذ refetch بشكل مباشر، يمكننا استخدام مفتاح الـ query:
type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray<Todo>
const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
}
export const useTodosQuery = (state: State) =>
useQuery({
queryKey: ['todos', state],
queryFn: () => fetchTodos(state),
})
هنا، تخيل أن مكوننا يعرض قائمة من المهام بالإضافة إلى خيار تصفية. سنحتاج إلى حالة بيانات محلية لتخزين تصفية هذه، وكلما يتغير اختيار المستخدم، سيتم تحديث هذه الحالة المحلية، وسيقوم React Query بتنفيذ refetch بشكل تلقائي لنا لأن مفتاح الـ query يتغير. نحن نحتفظ باختيار المستخدم في تسلسل مع الـ query function، وهو مشابه لما يمثله مصفوفة التبعية لـ useEffect. لم أفهم أنا أيضاً يمرر معامل إلى queryFn لم يكن جزءًا من مفتاح الـ query.
مدخل جديد للـ cache#
لأن مفتاح الـ query يستخدم كمفتاح للـ cache، ستحصل على مدخل جديد للـ cache عندما تنتقل من 'all' إلى 'done'، وسيؤدي ذلك إلى حالة تحميل صعبة (ربما سيظهر مؤشر تحميل) عندما تنتقل للمرة الأولى. هذا بالطبع غير مألوف، لذا إذا كان من الممكن، يمكننا محاولة تعبئة المدخل الجديد الذي تم إنشاؤه في الـ cache باستخدام initialData. المثال السابق مثال جيد لذلك، لأننا يمكننا إجراء بعض التصفية المحلية على مهامنا في الصفحة الصفراء:
type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray<Todo>
const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
}
export const useTodosQuery = (state: State) => {
useQuery({
queryKey: ['todos', state],
queryFn: () => fetchTodos(state),
initialData: () => {
const allTodos = queryClient.getQueryData<Todos>([
'todos',
'all',
])
const filteredData =
allTodos?.filter((todo) => todo.state === state) ?? []
return filteredData.length > 0 ? filteredData : undefined
},
})
الآن، في كل مرة ينتقل المستخدم بين الحالات، إذا لم يكن لدينا بيانات، يمكننا إظهار بيانات الـ 'all todos' الموجودة في الـ cache للمستخدم. يمكننا إظهار المهام المكتملة التي لدينا للمستخدم في أسرع وقت ممكن، وسيتم تحديث القائمة بمجرد إكمال عملية fetch الخلفية.
أعتقد أن هذا تحسين تجربة المستخدم الجيد لبضعة أسطر من الكود.
فصل بين حالة الخادم وحالة المستخدم#
هذا يتزامن مع putting-props-to-use-state، مقال أكتبه في الشهر الماضي: إذا حصلت على بيانات من useQuery، فيمكنك عدم وضع هذه البيانات في حالة بيانات محلية. السبب الرئيسي هو أنك تختار بشكل ضمني إيقاف جميع تحديثات الخلفية التي تقوم بها React Query لك، لأن الحالة "النسخ" ستتحدث معها.
هذا جيد إذا كنت تريد مثلاً جلب بعض القيم الافتراضية لنموذج معين، وتقديم النموذج مرة واحدة لديك بعد جلب البيانات. تحديثات الخلفية مستحيلة أن تعطي شيء جديد، وحتى إذا كانت، فإن النموذج قد تم تهيئته بالفعل. إذا فعلت ذلك بالإرادة، يرجى عدم إطلاق عمليات refetch غير مرغوب فيها بشكل عاطفي بوضع staleTime:
const App = () => {
const { data } = useQuery({
queryKey: ['key'],
queryFn,
staleTime: Infinity,
})
return data ? <MyForm initialData={data} /> : null
}
const MyForm = ({ initialData }) => {
const [data, setData] = React.useState(initialData)
...
}
هذا المفهوم سيكون أصعب في التتبع عندما تعرض بيانات يمكنك أيضًا أن تسمح للمستخدم بتحريرها، لكنه له العديد من المزايا. لقد أعددت مثال صغير في codesandbox:
الجزء الأهم من هذا المثال هو أننا لم نضع قيمة التلقائية التي نحصل عليها من React Query في حالة بيانات محلية. هذا يضمن لنا أن نرى أحدث البيانات دائمًا، لأنه لا يوجد نسخ "محلي" لها.
الخيار الممكن هو قوي جداً#
الـ useQuery hook لديه العديد من الخيارات التي يمكنك إرسالها لتخصيص سلوكه، والخيار enabled هو خيار ممكن قوي جداً يمكنك من إنجاز العديد من الأشياء الرائعة (الهجاء مقصور عليه). هنا قائمة سريعة للأشياء التي تمكنا من إنجازها بهذا الخيار:
-
Dependent Queries
جلب البيانات في query واحد والسماح لـ query ثانٍ بالتنفيذ فقط بعد الحصول على البيانات بنجاح من الـ query الأول.
-
تشغيل وإيقاف الـ queries
لدينا query يقوم بجلب البيانات بشكل منتظم بفضل refetchInterval، ولكن يمكننا إيقافه مؤقتاً إذا كان هناك Modal مفتوح لتجنب التحديثات في خلفية الشاشة.
-
انتظار إدخال المستخدم
وضع معايير التصفية في مفتاح الـ query، ولكن تعطيله طالما لم يقم المستخدم بتطبيق عوامل التصفية الخاصة به.
-
تعطيل query بعد إدخال المستخدم
على سبيل المثال، إذا كان لدينا قيمة مسودة يجب أن تكون لها الأولوية على بيانات الخادم. انظر المثال أعلاه.
لا تستخدم queryCache كمدير حالة بيانات محلية#
إذا قمت بتغيير queryCache (queryClient.setQueryData)، يجب أن تكون لهذا فقط لتحديثات الأمثلة التفضيلية أو كتب بياناتك من الخادم بعد تغيير البيانات. تذكر أن كل refetch خلفي قد يغلب على هذه البيانات، لذا استخدم شيء آخر آخر لحالة بيانات محلية.
إنشاء مخططات تخصيصية#
حتى إذا كانت هذه مجرد تغليف واحد لـ useQuery، إن إنشاء مخطط تخصيصي عادًا مجدي لأن:
- يمكنك إبقاء جلب البيانات الفعلي خارج الواجهة، ولكنه متصل بـ useQuery الخاص بك.
- يمكنك إبقاء جميع استخدامات مفتاح واحد لـ query (وربما تعريفات النوع) في ملف واحد.
- إذا كنت تحتاج لتعديل بعض الإعدادات أو إضافة بعض البيانات التحويلية، يمكنك إنجاز ذلك في مكان واحد.
- لقد رأيت مثال على ذلك في المهام السابقة.
لقد رأيت مثال على ذلك في المهام السابقة.
أتمنى أن تساعد هذه النصائح العملية في البدء بـ React Query، لذا يرجى التحقق منها :)