شرح بناء تطبيق CRUD لإدارة الكتب باستخدام React من الصفر
مقدمة: لماذا نبني تطبيق إدارة كتب باستخدام React؟
يُعد تطبيق إدارة الكتب مثالًا عمليًا ممتازًا لتعلّم أساسيات تطوير الواجهات الحديثة باستخدام React. فمن خلال هذا المشروع ستتعرّف على كيفية تنفيذ عمليات CRUD، والتنقّل بين الصفحات عبر React Router، وتخزين البيانات محليًا داخل المتصفح باستخدام localStorage، إلى جانب تنظيم الحالة ومشاركتها بين المكوّنات بطريقة نظيفة وقابلة للتوسع.
في هذا الدليل سنبني التطبيق من الصفر بصورة احترافية، مع شرح واضح للأفكار التقنية المهمة، بحيث ينتج لديك مشروع عملي مفيد وقابل للتطوير لاحقًا.

ما الذي ستتعلمه من هذا المشروع؟
- تنفيذ عمليات
CreateوReadوUpdateوDelete. - التنقّل بين المسارات باستخدام
React Router. - إنشاء
Custom Hookمخصص للتعامل معlocalStorage. - الاحتفاظ بالبيانات بعد تحديث الصفحة.
- إعادة استخدام نموذج واحد للإضافة والتعديل.
- استخدام
Context APIلتقليل تمرير الخصائص بين المكوّنات.
الإعداد الأولي للمشروع
ابدأ بإنشاء مشروع جديد عبر الأداة create-react-app:
npx create-react-app book-management-app
بعد إنشاء المشروع، احذف الملفات الموجودة داخل مجلد src ثم أنشئ الملفات والمجلدات التالية:
src/index.jssrc/styles.scsssrc/componentssrc/contextsrc/hookssrc/router
بعد ذلك ثبّت الاعتماديات المطلوبة:
yarn add bootstrap@4.6.0 lodash@4.17.21 react-bootstrap@1.5.2 node-sass@4.14.1 react-router-dom@5.2.0 uuid@8.3.2
هذه الحزم ستساعدك في:
- تنسيق الواجهة باستخدام
bootstrapوreact-bootstrap. - استخدام بعض الأدوات المفيدة عبر
lodash. - إدارة التنقّل عبر
react-router-dom. - توليد معرفات فريدة باستخدام
uuid. - كتابة الأنماط بصيغة
SCSSعبرnode-sass.
إنشاء الصفحات الأساسية للتطبيق
مكوّن الترويسة Header
أنشئ الملف Header.js داخل مجلد components وأضف الكود التالي:
import React from 'react';
import { NavLink } from 'react-router-dom';
const Header = () => {
return (
<header>
<h1>Book Management App</h1>
<hr />
<div className="links">
<NavLink to="/" className="link" activeClassName="active" exact>
Books List
</NavLink>
<NavLink to="/add" className="link" activeClassName="active">
Add Book
</NavLink>
</div>
</header>
);
};
export default Header;
يستخدم هذا المكوّن العنصر NavLink بدلًا من الوسم التقليدي <a> حتى يتم التنقّل داخل التطبيق دون إعادة تحميل الصفحة بالكامل، وهو سلوك أساسي في تطبيقات SPA.
مكوّن قائمة الكتب BooksList
import React from 'react';
const BooksList = () => {
return <h2>List of books</h2>;
};
export default BooksList;
مكوّن إضافة كتاب AddBook
import React from 'react';
import BookForm from './BookForm';
const AddBook = () => {
const handleOnSubmit = (book) => {
console.log(book);
};
return (
<React.Fragment>
<BookForm handleOnSubmit={handleOnSubmit} />
</React.Fragment>
);
};
export default AddBook;
هنا نعرض مكوّن BookForm ونمرّر له الدالة handleOnSubmit حتى نستخدم البيانات المُرسلة لاحقًا عند حفظ الكتاب.
بناء نموذج الكتاب القابل لإعادة الاستخدام
أنشئ الملف BookForm.js داخل مجلد components:
import React, { useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { v4 as uuidv4 } from 'uuid';
const BookForm = (props) => {
const [book, setBook] = useState({
bookname: props.book ? props.book.bookname : '',
author: props.book ? props.book.author : '',
quantity: props.book ? props.book.quantity : '',
price: props.book ? props.book.price : '',
date: props.book ? props.book.date : ''
});
const [errorMsg, setErrorMsg] = useState('');
const { bookname, author, price, quantity } = book;
const handleOnSubmit = (event) => {
event.preventDefault();
const values = [bookname, author, price, quantity];
let errorMsg = '';
const allFieldsFilled = values.every((field) => {
const value = `${field}`.trim();
return value !== '' && value !== '0';
});
if (allFieldsFilled) {
const book = {
id: uuidv4(),
bookname,
author,
price,
quantity,
date: new Date()
};
props.handleOnSubmit(book);
} else {
errorMsg = 'Please fill out all the fields.';
}
setErrorMsg(errorMsg);
};
const handleInputChange = (event) => {
const { name, value } = event.target;
switch (name) {
case 'quantity':
if (value === '' || parseInt(value) === +value) {
setBook((prevState) => ({ ...prevState, [name]: value }));
}
break;
case 'price':
if (value === '' || value.match(/^\d{1,}(\.\d{0,2})?$/)) {
setBook((prevState) => ({ ...prevState, [name]: value }));
}
break;
default:
setBook((prevState) => ({ ...prevState, [name]: value }));
}
};
return (
<div className="main-form">
{errorMsg && <p className="errorMsg">{errorMsg}</p>}
<Form onSubmit={handleOnSubmit}>
<Form.Group controlId="name">
<Form.Label>Book Name</Form.Label>
<Form.Control
className="input-control"
type="text"
name="bookname"
value={bookname}
placeholder="Enter name of book"
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group controlId="author">
<Form.Label>Book Author</Form.Label>
<Form.Control
className="input-control"
type="text"
name="author"
value={author}
placeholder="Enter name of author"
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group controlId="quantity">
<Form.Label>Quantity</Form.Label>
<Form.Control
className="input-control"
type="number"
name="quantity"
value={quantity}
placeholder="Enter available quantity"
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group controlId="price">
<Form.Label>Book Price</Form.Label>
<Form.Control
className="input-control"
type="text"
name="price"
value={price}
placeholder="Enter price of book"
onChange={handleInputChange}
/>
</Form.Group>
<Button variant="primary" type="submit" className="submit-btn">
Submit
</Button>
</Form>
</div>
);
};
export default BookForm;
كيف يعمل هذا النموذج؟
يعتمد المكوّن على الخطاف useState لتخزين بيانات الكتاب داخل كائن واحد. وهذا مفيد لأننا نتعامل مع عدة حقول مرتبطة بالكيان نفسه.
كما أن المكوّن مصمم ليعمل في حالتين:
- إضافة كتاب جديد.
- تعديل كتاب موجود.
ولهذا نتحقق أولًا من وجود الخاصية props.book. إذا كانت موجودة نملأ الحقول بقيمها، وإلا نبدأ بقيم فارغة.
التحقق من صحة المدخلات
داخل الدالة handleInputChange توجد معالجة دقيقة لبعض الحقول:
- حقل
quantityيقبل أعدادًا صحيحة فقط. - حقل
priceيقبل أرقامًا عشرية حتى منزلتين فقط بعد الفاصلة.
هذا يرفع جودة البيانات المخزنة ويمنع إدخال قيم غير مناسبة.
أما عند الإرسال، فتستخدم الدالة every() للتأكد من أن جميع الحقول الأساسية مملوءة، وأن القيمة ليست فارغة أو مساوية لـ 0.
إعداد نظام التوجيه باستخدام React Router
أنشئ الملف AppRouter.js داخل مجلد router:
import React from 'react';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import Header from '../components/Header';
import AddBook from '../components/AddBook';
import BooksList from '../components/BooksList';
const AppRouter = () => {
return (
<BrowserRouter>
<div>
<Header />
<div className="main-content">
<Switch>
<Route component={BooksList} path="/" exact={true} />
<Route component={AddBook} path="/add" />
</Switch>
</div>
</div>
</BrowserRouter>
);
};
export default AppRouter;
بهذا أصبح لدينا مساران أساسيان:
/لعرض قائمة الكتب./addلإضافة كتاب جديد.
ملف التشغيل الرئيسي index.js
import React from 'react';
import ReactDOM from 'react-dom';
import AppRouter from './router/AppRouter';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.scss';
ReactDOM.render(<AppRouter />, document.getElementById('root'));
ثم شغّل المشروع:
yarn start


لماذا نستخدم localStorage في هذا التطبيق؟
عند تخزين البيانات داخل الحالة فقط، فإنها تختفي بمجرد تحديث الصفحة. أما باستخدام localStorage فتظل البيانات محفوظة في المتصفح حتى بعد إعادة التحميل أو إغلاق الصفحة.
هذه الطريقة مناسبة للمشاريع التعليمية، ولوحات التحكم البسيطة، وبعض حالات التخزين المحلي المؤقت.
إضافة قيمة إلى localStorage تتم عبر:
localStorage.setItem(key, value)
وبما أن التخزين يقبل نصوصًا فقط، فإننا نحول الكائنات إلى نص بصيغة JSON باستخدام JSON.stringify()، ثم نستعيدها عبر JSON.parse().
إنشاء Custom Hook للتعامل مع التخزين المحلي
أنشئ الملف useLocalStorage.js داخل مجلد hooks:
import { useState, useEffect } from 'react';
const useLocalStorage = (key, initialValue) => {
const [value, setValue] = useState(() => {
try {
const localValue = window.localStorage.getItem(key);
return localValue ? JSON.parse(localValue) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
};
export default useLocalStorage;
ما فائدة هذا الخطاف المخصص؟
بدلًا من تكرار منطق القراءة والكتابة من localStorage داخل أكثر من مكوّن، نقوم بعزله داخل خطاف واحد قابل لإعادة الاستخدام.
كما أن استخدام التهيئة الكسولة داخل useState يعني أن القراءة من التخزين المحلي تتم مرة واحدة عند تحميل المكوّن، وهو تحسين بسيط لكنه مهم في التطبيقات الأكبر.
ربط التطبيق بالتخزين المحلي
عدّل الملف AppRouter.js لاستخدام الخطاف useLocalStorage:
import React from 'react';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import Header from '../components/Header';
import AddBook from '../components/AddBook';
import BooksList from '../components/BooksList';
import useLocalStorage from '../hooks/useLocalStorage';
const AppRouter = () => {
const [books, setBooks] = useLocalStorage('books', []);
return (
<BrowserRouter>
<div>
<Header />
<div className="main-content">
<Switch>
<Route component={BooksList} path="/" exact={true} />
<Route
render={(props) => (
<AddBook {...props} books={books} setBooks={setBooks} />
)}
path="/add"
/>
</Switch>
</div>
</div>
</BrowserRouter>
);
};
export default AppRouter;
ثم حدّث الملف AddBook.js:
import React from 'react';
import BookForm from './BookForm';
const AddBook = ({ history, books, setBooks }) => {
const handleOnSubmit = (book) => {
setBooks([book, ...books]);
history.push('/');
};
return (
<React.Fragment>
<BookForm handleOnSubmit={handleOnSubmit} />
</React.Fragment>
);
};
export default AddBook;
هنا نقوم بإضافة الكتاب الجديد في مقدمة المصفوفة باستخدام معامل الانتشار ...، ثم نعيد المستخدم إلى الصفحة الرئيسية عبر history.push('/').

عرض الكتب المخزنة داخل واجهة المستخدم
الخطوة التالية هي تمرير البيانات إلى مكوّن عرض القائمة.
عدّل AppRouter.js بالشكل التالي:
import React from 'react';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import Header from '../components/Header';
import AddBook from '../components/AddBook';
import BooksList from '../components/BooksList';
import useLocalStorage from '../hooks/useLocalStorage';
const AppRouter = () => {
const [books, setBooks] = useLocalStorage('books', []);
return (
<BrowserRouter>
<div>
<Header />
<div className="main-content">
<Switch>
<Route
render={(props) => (
<BooksList {...props} books={books} setBooks={setBooks} />
)}
path="/"
exact={true}
/>
<Route
render={(props) => (
<AddBook {...props} books={books} setBooks={setBooks} />
)}
path="/add"
/>
</Switch>
</div>
</div>
</BrowserRouter>
);
};
export default AppRouter;
مكوّن عرض الكتاب Book
import React from 'react';
import { Button, Card } from 'react-bootstrap';
const Book = ({ id, bookname, author, price, quantity, date, handleRemoveBook }) => {
return (
<Card style={{ width: '18rem' }} className="book">
<Card.Body>
<Card.Title className="book-title">{bookname}</Card.Title>
<div className="book-details">
<div>Author: {author}</div>
<div>Quantity: {quantity}</div>
<div>Price: {price}</div>
<div>Date: {new Date(date).toDateString()}</div>
</div>
<Button variant="primary">Edit</Button>{' '}
<Button variant="danger" onClick={() => handleRemoveBook(id)}>
Delete
</Button>
</Card.Body>
</Card>
);
};
export default Book;
تحديث مكوّن القائمة BooksList
import React from 'react';
import _ from 'lodash';
import Book from './Book';
const BooksList = ({ books, setBooks }) => {
const handleRemoveBook = (id) => {
setBooks(books.filter((book) => book.id !== id));
};
return (
<React.Fragment>
<div className="book-list">
{!_.isEmpty(books) ? (
books.map((book) => (
<Book key={book.id} {...book} handleRemoveBook={handleRemoveBook} />
))
) : (
<p className="message">No books available. Please add some books.</p>
)}
</div>
</React.Fragment>
);
};
export default BooksList;
تُستخدم الدالة filter() هنا لحذف الكتاب المطلوب عبر الاحتفاظ بكل العناصر التي لا تتطابق مع المعرّف id المحدد.


إضافة ميزة تعديل الكتب
حتى تكتمل دورة CRUD، نحتاج إلى تعديل السجلات الموجودة.
تحديث زر التعديل داخل Book.js
import React from 'react';
import { Button, Card } from 'react-bootstrap';
import { useHistory } from 'react-router-dom';
const Book = ({ id, bookname, author, price, quantity, date, handleRemoveBook }) => {
const history = useHistory();
return (
<Card style={{ width: '18rem' }} className="book">
<Card.Body>
<Card.Title className="book-title">{bookname}</Card.Title>
<div className="book-details">
<div>Author: {author}</div>
<div>Quantity: {quantity}</div>
<div>Price: {price}</div>
<div>Date: {new Date(date).toDateString()}</div>
</div>
<Button variant="primary" onClick={() => history.push(`/edit/${id}`)}>
Edit
</Button>{' '}
<Button variant="danger" onClick={() => handleRemoveBook(id)}>
Delete
</Button>
</Card.Body>
</Card>
);
};
export default Book;
استخدمنا الخطاف useHistory للوصول إلى كائن history داخل مكوّن غير مُعرّف مباشرة داخل Route.
إنشاء مكوّن التعديل EditBook
import React from 'react';
import BookForm from './BookForm';
import { useParams } from 'react-router-dom';
const EditBook = ({ history, books, setBooks }) => {
const { id } = useParams();
const bookToEdit = books.find((book) => book.id === id);
const handleOnSubmit = (book) => {
const filteredBooks = books.filter((book) => book.id !== id);
setBooks([book, ...filteredBooks]);
history.push('/');
};
return (
<div>
<BookForm book={bookToEdit} handleOnSubmit={handleOnSubmit} />
</div>
);
};
export default EditBook;
إضافة مسار التعديل داخل AppRouter.js
import React from 'react';
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
import Header from '../components/Header';
import AddBook from '../components/AddBook';
import BooksList from '../components/BooksList';
import EditBook from '../components/EditBook';
import useLocalStorage from '../hooks/useLocalStorage';
const AppRouter = () => {
const [books, setBooks] = useLocalStorage('books', []);
return (
<BrowserRouter>
<div>
<Header />
<div className="main-content">
<Switch>
<Route
render={(props) => (
<BooksList {...props} books={books} setBooks={setBooks} />
)}
path="/"
exact={true}
/>
<Route
render={(props) => (
<AddBook {...props} books={books} setBooks={setBooks} />
)}
path="/add"
/>
<Route
render={(props) => (
<EditBook {...props} books={books} setBooks={setBooks} />
)}
path="/edit/:id"
/>
<Route component={() => <Redirect to="/" />} />
</Switch>
</div>
</div>
</BrowserRouter>
);
};
export default AppRouter;

كيف تعمل عملية التعديل؟
- عند الضغط على زر
Editيتم الانتقال إلى المسار/edit/:id. - يُقرأ المعرّف من الرابط باستخدام
useParams(). - يتم البحث عن الكتاب المطابق داخل مصفوفة الكتب عبر
find(). - تُمرر البيانات إلى مكوّن
BookFormليُعرض النموذج بالقيم الحالية. - بعد الحفظ، تُستبدل النسخة القديمة ببيانات الكتاب المعدلة.
تحسين الأداء باستخدام التهيئة الكسولة في useState
يمكن تحسين تعريف الحالة داخل BookForm بدلًا من تمرير الكائن مباشرة، من خلال استخدام دالة تُنفّذ مرة واحدة فقط عند تحميل المكوّن:
const [book, setBook] = useState(() => {
return {
bookname: props.book ? props.book.bookname : '',
author: props.book ? props.book.author : '',
quantity: props.book ? props.book.quantity : '',
price: props.book ? props.book.price : '',
date: props.book ? props.book.date : ''
};
});
هذا الأسلوب مفيد عندما تكون عملية التهيئة غير بسيطة أو عند الحاجة لتجنب إعادة الحساب في كل عملية إعادة رسم re-render.
تبسيط تمرير البيانات باستخدام React Context API
مع توسع التطبيق، يصبح تمرير الخصائص من الموجّه إلى كل مكوّن أمرًا مرهقًا. وهنا يظهر دور Context API لتوفير طريقة مركزية لمشاركة البيانات بين المكوّنات.
إنشاء السياق BooksContext
import React from 'react';
const BooksContext = React.createContext();
export default BooksContext;
تغليف المكوّنات داخل Provider
import React from 'react';
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
import Header from '../components/Header';
import AddBook from '../components/AddBook';
import BooksList from '../components/BooksList';
import EditBook from '../components/EditBook';
import useLocalStorage from '../hooks/useLocalStorage';
import BooksContext from '../context/BooksContext';
const AppRouter = () => {
const [books, setBooks] = useLocalStorage('books', []);
return (
<BrowserRouter>
<div>
<Header />
<div className="main-content">
<BooksContext.Provider value={{ books, setBooks }}>
<Switch>
<Route component={BooksList} path="/" exact={true} />
<Route component={AddBook} path="/add" />
<Route component={EditBook} path="/edit/:id" />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BooksContext.Provider>
</div>
</div>
</BrowserRouter>
);
};
export default AppRouter;
استخدام useContext داخل BooksList
import React, { useContext } from 'react';
import _ from 'lodash';
import Book from './Book';
import BooksContext from '../context/BooksContext';
const BooksList = () => {
const { books, setBooks } = useContext(BooksContext);
const handleRemoveBook = (id) => {
setBooks(books.filter((book) => book.id !== id));
};
return (
<React.Fragment>
<div className="book-list">
{!_.isEmpty(books) ? (
books.map((book) => (
<Book key={book.id} {...book} handleRemoveBook={handleRemoveBook} />
))
) : (
<p className="message">No books available. Please add some books.</p>
)}
</div>
</React.Fragment>
);
};
export default BooksList;
استخدام useContext داخل AddBook
import React, { useContext } from 'react';
import BookForm from './BookForm';
import BooksContext from '../context/BooksContext';
const AddBook = ({ history }) => {
const { books, setBooks } = useContext(BooksContext);
const handleOnSubmit = (book) => {
setBooks([book, ...books]);
history.push('/');
};
return (
<React.Fragment>
<BookForm handleOnSubmit={handleOnSubmit} />
</React.Fragment>
);
};
export default AddBook;
استخدام useContext داخل EditBook
import React, { useContext } from 'react';
import BookForm from './BookForm';
import { useParams } from 'react-router-dom';
import BooksContext from '../context/BooksContext';
const EditBook = ({ history }) => {
const { books, setBooks } = useContext(BooksContext);
const { id } = useParams();
const bookToEdit = books.find((book) => book.id === id);
const handleOnSubmit = (book) => {
const filteredBooks = books.filter((book) => book.id !== id);
setBooks([book, ...filteredBooks]);
history.push('/');
};
return (
<div>
<BookForm book={bookToEdit} handleOnSubmit={handleOnSubmit} />
</div>
);
};
export default EditBook;
بهذه الخطوة أصبح الكود أنظف وأكثر قابلية للصيانة، خصوصًا إذا زاد عدد الصفحات أو المكوّنات في المستقبل.

أفضل الممارسات التقنية في هذا المشروع
- إعادة استخدام مكوّن
BookFormللإضافة والتعديل بدل تكرار الكود. - عزل منطق التخزين المحلي داخل الخطاف
useLocalStorage. - الاعتماد على
Context APIعندما تتكرر الحاجة إلى تمرير البيانات. - التحقق من صحة المدخلات قبل الحفظ لتحسين جودة البيانات.
- استخدام معرفات فريدة عبر
uuidv4()لتجنّب التعارض.
متى يكون هذا الأسلوب مناسبًا؟
هذا النمط مناسب جدًا عند بناء:
- مشروعات تدريبية لتعلّم
React. - تطبيقات صغيرة لا تحتاج إلى خادم خلفي في البداية.
- نماذج أولية
Prototypeسريعة. - واجهات إدارة بسيطة تعتمد على التخزين المحلي.
أما إذا كان التطبيق سيخدم عدة مستخدمين أو يحتاج إلى مزامنة البيانات بين الأجهزة، فالأفضل لاحقًا استبدال localStorage بواجهة برمجة تطبيقات API وقاعدة بيانات فعلية.
الخلاصة التقنية
هذا المشروع يقدّم مسارًا عمليًا متكاملًا لتعلّم بناء تطبيق CRUD في React بطريقة واضحة وقابلة للتطوير. الأهم من مجرد كتابة الكود هو فهم سبب استخدام كل أداة: فـReact Router ينظم التنقل، وlocalStorage يحافظ على البيانات، وCustom Hooks تقلل التكرار، وContext API تجعل البنية أنظف. إذا أتقنت هذا النمط، فستكون جاهزًا للانتقال إلى تطبيقات أكبر تستخدم APIs حقيقية وإدارة حالة أكثر تقدمًا.