Redux Toolkit: مخزن واحد للتطبيق
حالة التطبيق الكبير في مكان واحد يمكن تتبعه. ⏱ نحو 35 دقيقة.
- تشرح متى تحتاج
Redux، وتقارنه بـContextوVuex. - تنشئ شريحة بـ
createSliceومخزنًا بـconfigureStore. - تقرأ الحالة بـ
useSelectorوترسل الأفعال بـuseDispatch. - تكتب محدِّدات للقيم المحسوبة، وتفهم دور
Immer.
١لماذا Redux؟
في التطبيقات الكبيرة (متجر، أو نظام إدارة) تتشارك عشرات المكوّنات حالة كثيرة: المستخدم، والسلة، والطلبات، والإشعارات. Redux يضع حالة التطبيق كلها في مخزن Store واحد خارج المكوّنات، ويغيّرها بالأسلوب الذي تعلمته في الدرس السابق: أفعال تُرسل إلى مخفّضات.
واليوم يُكتب Redux بـ Redux Toolkit (RTK)، الطريقة الرسمية الموصى بها، لأنه يختصر الكود المكرر. وتذكر الخطة أيضًا Vuex: الفكرة نفسها في عالم Vue (وخلفه اليوم Pinia).
Context + useReducer | Redux Toolkit | |
|---|---|---|
| الحجم المناسب | تطبيق صغير أو متوسط | تطبيق كبير بحالة كثيرة متشابكة |
| أدوات المطور | محدودة | Redux DevTools: ترى كل فعل وتعود بالزمن |
| الأداء | كل من يقرأ السياق يُعاد رسمه | المكوّن يُعاد رسمه فقط إن تغيّر ما يختاره |
| التعلّم | جزء من React | مكتبة إضافية بمفاهيم جديدة |
٢القطع الأربع
| القطعة | الدالة | وظيفتها |
|---|---|---|
| الشريحة Slice | createSlice | جزء من الحالة (السلة) مع مخفّضاته، وتنشئ الأفعال تلقائيًّا. |
| المخزن Store | configureStore | يجمع الشرائح في حالة واحدة. |
| المزوّد | <Provider store={store}> | يجعل المخزن متاحًا لكل المكوّنات. |
| الخطّافات | useSelector و useDispatch | المكوّن يختار ما يحتاجه من الحالة، ويرسل الأفعال. |
افتح الملفات الخمسة بالترتيب: الشريحة ثم المخزن ثم App ثم المكوّنان:
import { Provider } from 'react-redux';import { store } from './store.js';import Header from './Header.jsx';import Menu from './Menu.jsx'; export default function App() { return ( <Provider store={store}> <Header /> <Menu /> </Provider> );}
import { createSlice } from '@reduxjs/toolkit'; const cartSlice = createSlice({ name: 'cart', initialState: { items: [] }, reducers: { added(state, action) { const found = state.items.find(i => i.id === action.payload.id); if (found) found.qty++; // مسموح هنا! (انظر الشرح) else state.items.push({ ...action.payload, qty: 1 }); }, removed(state, action) { state.items = state.items.filter(i => i.id !== action.payload); }, cleared(state) { state.items = []; }, },}); export const { added, removed, cleared } = cartSlice.actions;export default cartSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';import cartReducer from './cartSlice.js'; export const store = configureStore({ reducer: { cart: cartReducer, // state.cart يديره هذا المخفّض },});
import { useSelector, useDispatch } from 'react-redux';import { cleared } from './cartSlice.js'; export default function Header() { const items = useSelector(state => state.cart.items); const dispatch = useDispatch(); const total = items.reduce((s, i) => s + i.price * i.qty, 0); return ( <header> 🛒 {items.length} أصناف · {total} ريال{' '} <button onClick={() => dispatch(cleared())}>تفريغ</button> </header> );}
import { useDispatch } from 'react-redux';import { added } from './cartSlice.js'; const menu = [ { id: 1, name: 'قهوة', price: 35 }, { id: 2, name: 'تمر', price: 45 },]; export default function Menu() { const dispatch = useDispatch(); return ( <ul> {menu.map(p => ( <li key={p.id}>{p.name} <button onClick={() => dispatch(added(p))}>أضف</button></li> ))} </ul> );}
٣«تعديل» الحالة داخل الشريحة؟
لاحظت في cartSlice: found.qty++ و state.items.push(…). ألم نقل لا تعدّل الحالة؟ داخل createSlice فقط يجوز ذلك، لأن RTK يستخدم مكتبة اسمها Immer: تكتب كأنك تعدّل، وهي تنتج نسخة جديدة فعليًّا في الخلفية.
| خارج createSlice | داخل مخفّضات createSlice | |
|---|---|---|
| إضافة | [...items, x] | state.items.push(x) ✓ |
| تعديل | items.map(…{...i, qty: i.qty + 1}…) | found.qty++ ✓ |
| استبدال الكل | return { items: [] } | state.items = [] ✓ (أو return) |
والفعل الذي تنشئه الشريحة كائن عادي؛ القيمة التي تمررها تصل في action.payload:
import { added, removed } from './cartSlice.js'; const a = added({ id: 7, name: 'شاي', price: 18 });console.log(a.type);console.log(JSON.stringify(a.payload));console.log(removed(7).type, removed(7).payload); export default function App() { return <p>الأفعال في Console.</p>;}
import { createSlice } from '@reduxjs/toolkit'; const cartSlice = createSlice({ name: 'cart', initialState: { items: [] }, reducers: { added(state, action) { const found = state.items.find(i => i.id === action.payload.id); if (found) found.qty++; // مسموح هنا! (انظر الشرح) else state.items.push({ ...action.payload, qty: 1 }); }, removed(state, action) { state.items = state.items.filter(i => i.id !== action.payload); }, cleared(state) { state.items = []; }, },}); export const { added, removed, cleared } = cartSlice.actions;export default cartSlice.reducer;
٤القيم المحسوبة: المحدِّدات
لا تخزّن ما يمكن حسابه (الإجمالي، وعدد العناصر). اكتب دوال صغيرة تختار وتحسب من الحالة، اسمها المحدِّدات Selectors، وضعها مع الشريحة لتستخدمها المكوّنات كلها:
// في cartSlice.jsexport const selectItems = state => state.cart.items;export const selectCount = state => state.cart.items.reduce((s, i) => s + i.qty, 0);export const selectTotal = state => state.cart.items.reduce((s, i) => s + i.price * i.qty, 0); // في أي مكوّنconst total = useSelector(selectTotal);
وبعد أن نبني الخادم، تأتي بيانات المنتجات من API. يقدّم RTK لذلك أداتين: createAsyncThunk لطلبات غير متزامنة، و RTK Query لجلب البيانات وتخزينها مؤقتًا. سنعود لهما في الوحدة 7.
توقّع وتحقّق
أين تُحفظ حالة التطبيق في Redux؟
Single source of truth.القيمة الممررة إلى مُنشئ الفعل تصل في:
payload.لماذا يجوز state.items.push داخل createSlice؟
Immer.ليقرأ المكوّن جزءًا من المخزن يستخدم:
الخلاصة
Redux: حالة التطبيق في مخزن واحد، وتتغير بأفعال تُرسل إلى مخفّضات.createSlice: الاسم والحالة الأولى والمخفّضات، وتولّد الأفعال تلقائيًّا.configureStore({ reducer: { cart: cartReducer } })ثم<Provider store={store}>.useSelector(s => s.cart.items)للقراءة، وdispatch(added(p))للتغيير.- داخل
createSliceفقط يجوز «التعديل» بفضلImmer.
تحدٍّ برمجي: شريحة الإشعارات
متقدمأضف إلى التطبيق شريحة إشعارات بـ Redux Toolkit.
- شريحة
notesحالتها الأولى[]، ومخفّضاها:pushed(يضيف نصًّا في payload) وdismissed(يحذف الإشعار بترتيبه index في payload). - المخزن فيه الشريحة بالاسم notes.
- زر بالمعرّف
id="new"يرسل pushed بنص «إشعار جديد»، وكل إشعار<li>فيه زر «✕» يرسل dismissed. - عنوان
<h2>الإشعارات (2)</h2>بالعدد عبر useSelector.
👀 اعرض الناتج المطلوب (وجرّبه)
- الكود يعمل بلا أخطاء في Console
- في البداية: الإشعارات
(0) - ثلاث ضغطات على #new تضيف ثلاثة
- «✕» يحذف إشعارًا
- تستخدم createSlice و configureStore و Provider
- تستخدم useSelector و useDispatch
💡 تلميح 1
state.push(…) و state.splice(i, 1).💡 تلميح 2
حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
import { configureStore, createSlice } from '@reduxjs/toolkit';import { Provider, useSelector, useDispatch } from 'react-redux'; const notes = createSlice({ name: 'notes', initialState: [], reducers: { pushed(state, action) { state.push(action.payload); }, dismissed(state, action) { state.splice(action.payload, 1); }, },});const { pushed, dismissed } = notes.actions;const store = configureStore({ reducer: { notes: notes.reducer } }); function Notes() { const list = useSelector(s => s.notes); const dispatch = useDispatch(); return ( <> <h2>الإشعارات ({list.length})</h2> <button id="new" onClick={() => dispatch(pushed('إشعار جديد'))}>جديد</button> <ul> {list.map((n, i) => ( <li key={i}>{n} <button onClick={() => dispatch(dismissed(i))}>✕</button></li> ))} </ul> </> );} export default function App() { return <Provider store={store}><Notes /></Provider>;}