العمليات غير المتزامنة مع Redux
يوفر Redux Toolkit دالة createAsyncThunk للتعامل مع العمليات غير المتزامنة مثل استدعاءات API. تقوم تلقائيًا بإرسال إجراءات pending و fulfilled و rejected، مما يجعل إدارة الحالة غير المتزامنة واضحة ويمكن التنبؤ بها.
فهم createAsyncThunk
تولد createAsyncThunk منشئي الإجراءات وتتعامل مع دورة الحياة غير المتزامنة تلقائيًا:
دورة حياة الإجراء غير المتزامن:
- pending: يتم إرساله عند بدء العملية غير المتزامنة
- fulfilled: يتم إرساله عند نجاح العملية
- rejected: يتم إرساله عند فشل العملية
- يمكن لكل حالة تحديث أجزاء مختلفة من مخزن Redux
- يتضمن تلقائيًا معالجة الأخطاء وحالات التحميل
إنشاء Async Thunks
لنبني جالب ملف تعريف مستخدم مع createAsyncThunk:
// store/slices/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// تعريف الـ async thunk
export const fetchUserById = createAsyncThunk(
'users/fetchById', // بادئة نوع الإجراء
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
throw new Error('فشل في جلب المستخدم');
}
const data = await response.json();
return data; // يصبح action.payload في حالة fulfilled
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// جلب جميع المستخدمين
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) throw new Error('فشل في جلب المستخدمين');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// تحديث مستخدم
export const updateUser = createAsyncThunk(
'users/update',
async ({ userId, updates }, { rejectWithValue }) => {
try {
const response = await fetch(
`https://api.example.com/users/${userId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
}
);
if (!response.ok) throw new Error('فشل في تحديث المستخدم');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const initialState = {
users: [],
currentUser: null,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null
};
const userSlice = createSlice({
name: 'users',
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
clearCurrentUser: (state) => {
state.currentUser = null;
}
},
extraReducers: (builder) => {
// جلب مستخدم واحد
builder
.addCase(fetchUserById.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.status = 'succeeded';
state.currentUser = action.payload;
})
.addCase(fetchUserById.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload || 'حدث خطأ ما';
});
// جلب جميع المستخدمين
builder
.addCase(fetchUsers.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = 'succeeded';
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
// تحديث مستخدم
builder
.addCase(updateUser.fulfilled, (state, action) => {
const index = state.users.findIndex(
user => user.id === action.payload.id
);
if (index !== -1) {
state.users[index] = action.payload;
}
if (state.currentUser?.id === action.payload.id) {
state.currentUser = action.payload;
}
});
}
});
export const { clearError, clearCurrentUser } = userSlice.actions;
export default userSlice.reducer;
// المحددات
export const selectAllUsers = (state) => state.users.users;
export const selectCurrentUser = (state) => state.users.currentUser;
export const selectUserStatus = (state) => state.users.status;
export const selectUserError = (state) => state.users.error;
كائن ThunkAPI: يوفر المعامل الثاني لـ createAsyncThunk أدوات مفيدة: dispatch (إرسال إجراءات)، getState (الوصول لحالة Redux)، rejectWithValue (معالجة أخطاء مخصصة)، signal (AbortController للإلغاء)، و extra (معاملات middleware مخصصة).
استخدام Async Thunks في المكونات
أرسل thunks تمامًا مثل الإجراءات العادية وتعامل مع حالات التحميل:
// components/UserProfile.jsx
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
fetchUserById,
selectCurrentUser,
selectUserStatus,
selectUserError,
clearCurrentUser
} from '../store/slices/userSlice';
function UserProfile({ userId }) {
const dispatch = useDispatch();
const user = useSelector(selectCurrentUser);
const status = useSelector(selectUserStatus);
const error = useSelector(selectUserError);
useEffect(() => {
if (userId) {
// Dispatch يرجع promise
dispatch(fetchUserById(userId));
}
return () => {
dispatch(clearCurrentUser());
};
}, [userId, dispatch]);
if (status === 'loading') {
return (
<div className="loading">
<div className="spinner"></div>
<p>جاري تحميل ملف المستخدم...</p>
</div>
);
}
if (status === 'failed') {
return (
<div className="error">
<p>خطأ: {error}</p>
<button onClick={() => dispatch(fetchUserById(userId))}>
إعادة المحاولة
</button>
</div>
);
}
if (!user) {
return <div>لم يتم العثور على مستخدم</div>;
}
return (
<div className="user-profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.bio}</p>
</div>
);
}
معالجة نتائج Promise
createAsyncThunk ترجع promise، مما يسمح لك بمعالجة النتائج في المكونات:
// components/EditUserForm.jsx
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { updateUser } from '../store/slices/userSlice';
function EditUserForm({ user }) {
const dispatch = useDispatch();
const [formData, setFormData] = useState({
name: user.name,
email: user.email,
bio: user.bio
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
// ()unwrap ترمي خطأ عند الرفض، وترجع payload عند النجاح
const result = await dispatch(
updateUser({ userId: user.id, updates: formData })
).unwrap();
alert('تم تحديث المستخدم بنجاح!');
console.log('المستخدم المحدث:', result);
} catch (error) {
alert(`فشل التحديث: ${error}`);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({
...formData,
name: e.target.value
})}
/>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({
...formData,
email: e.target.value
})}
/>
<textarea
value={formData.bio}
onChange={(e) => setFormData({
...formData,
bio: e.target.value
})}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'جاري الحفظ...' : 'حفظ التغييرات'}
</button>
</form>
);
}
معالجة الأخطاء: استخدم دائمًا try-catch عند استدعاء ()unwrap على thunks المرسلة. بدون ()unwrap، لا يرفض الـ promise أبدًا - تحقق من action.error في ()then بدلاً من ذلك. طريقة ()unwrap تجعل معالجة الأخطاء أكثر سهولة لمنطق مستوى المكون.
متقدم: Thunks الشرطية
امنع استدعاءات API غير الضرورية بفحص الحالة قبل التنفيذ:
// store/slices/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchPosts = createAsyncThunk(
'posts/fetch',
async (_, { getState, rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) throw new Error('فشل الجلب');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
},
{
// دالة الشرط - أرجع false لإلغاء الإرسال
condition: (_, { getState }) => {
const { posts } = getState();
const { status, lastFetch } = posts;
// لا تجلب إذا كان التحميل جاريًا
if (status === 'loading') {
return false;
}
// لا تجلب إذا كانت البيانات حديثة (أقل من 5 دقائق)
if (lastFetch) {
const fiveMinutes = 5 * 60 * 1000;
const timeSinceLastFetch = Date.now() - lastFetch;
if (timeSinceLastFetch < fiveMinutes) {
return false;
}
}
return true;
}
}
);
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
status: 'idle',
error: null,
lastFetch: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
state.lastFetch = Date.now();
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
}
});
export default postsSlice.reducer;
التحديثات التفاؤلية
حدث واجهة المستخدم فورًا، ثم تزامن مع الخادم:
// store/slices/todosSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const toggleTodo = createAsyncThunk(
'todos/toggle',
async (todoId, { rejectWithValue }) => {
try {
const response = await fetch(
`https://api.example.com/todos/${todoId}/toggle`,
{ method: 'PATCH' }
);
if (!response.ok) throw new Error('فشل التبديل');
return await response.json();
} catch (error) {
return rejectWithValue({ todoId, error: error.message });
}
}
);
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
optimisticUpdates: {}
},
reducers: {
toggleTodoOptimistic: (state, action) => {
const todo = state.items.find(t => t.id === action.payload);
if (todo) {
// تخزين الحالة الأصلية للتراجع
state.optimisticUpdates[todo.id] = todo.completed;
todo.completed = !todo.completed;
}
}
},
extraReducers: (builder) => {
builder
.addCase(toggleTodo.fulfilled, (state, action) => {
// تأكيد التحديث التفاؤلي
delete state.optimisticUpdates[action.payload.id];
const todo = state.items.find(t => t.id === action.payload.id);
if (todo) {
todo.completed = action.payload.completed;
}
})
.addCase(toggleTodo.rejected, (state, action) => {
// التراجع عن التحديث التفاؤلي
const { todoId } = action.payload;
const originalState = state.optimisticUpdates[todoId];
if (originalState !== undefined) {
const todo = state.items.find(t => t.id === todoId);
if (todo) {
todo.completed = originalState;
}
delete state.optimisticUpdates[todoId];
}
});
}
});
export const { toggleTodoOptimistic } = todosSlice.actions;
export default todosSlice.reducer;
// الاستخدام في المكون
const handleToggle = (todoId) => {
dispatch(toggleTodoOptimistic(todoId)); // تحديث واجهة المستخدم الفوري
dispatch(toggleTodo(todoId)); // مزامنة مع الخادم
};
تمرين عملي 1: API المنتجات مع الترقيم
ابنِ قائمة منتجات مرقمة:
- أنشئ fetchProducts thunk يقبل معاملات page و limit
- خزن products، currentPage، totalPages، وحالة التحميل
- نفذ مكون ProductList مع عناصر تحكم الترقيم
- أضف searchProducts thunk مع debouncing
- تعامل مع حالات التحميل بشاشات الهيكل العظمي
تمرين عملي 2: رفع الملفات مع التقدم
أنشئ نظام رفع صور:
- ابنِ uploadImage thunk مع FormData وتتبع التقدم
- استخدم XMLHttpRequest أو fetch مع ReadableStream للتقدم
- خزن نسبة تقدم الرفع في Redux
- اعرض شريط تقدم أثناء الرفع
- تعامل مع إلغاء الرفع باستخدام AbortController
تمرين عملي 3: مزامنة سلة التسوق
ابنِ سلة تتزامن مع الواجهة الخلفية:
- أنشئ addToCart، removeFromCart، updateQuantity thunks
- نفذ تحديثات تفاؤلية لردود فعل واجهة المستخدم الفورية
- أضف آلية تراجع لاستدعاءات API الفاشلة
- خزن حالة المزامنة (synced/syncing/error) لكل عنصر
- اعرض مؤشرات المزامنة بجانب كل عنصر في السلة
الخلاصة
تبسط createAsyncThunk عمليات Redux غير المتزامنة من خلال التعامل التلقائي مع حالات pending و fulfilled و rejected. استخدم extraReducers للتعامل مع إجراءات دورة الحياة هذه، استفد من ThunkAPI للميزات المتقدمة، ونفذ أنماطًا مثل الجلب الشرطي والتحديثات التفاؤلية لتجربة مستخدم أفضل. تعامل دائمًا مع الأخطاء بشكل صحيح باستخدام try-catch و rejectWithValue.