EduFlow
أساسيات React
⁦0%⁩

الوحدة 4 · إطلاق مشروع حقيقي

بناء تطبيق Watchlist

26 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

اجمع Watchlist المكتمل من components صغيرة، مع الـ state عند مالك واحد، وقيم مشتقّة، ومعالجات تُمرَّر إلى الأسفل، وقائمة محفوظة في localStorage.

  1. 1شكل التطبيق
  2. 2التخزين، معزولًا
  3. 3الـ App: المالك
  4. 4القطع التي تحت
  5. 5لماذا تصمد هذه البنية
  6. 6حين تكبر المعالجات

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

كل قطعة من Watchlist موجودة الآن، موزّعة على الدروس: الـ components من القسم 1، والـ props والقوائم من القسم 2، والـ state والنماذج والـ effects من القسم 3. هذا الدرس يجمعها في تطبيق واحد ستكون مرتاحًا لعرضه في مقابلة عمل، ويشرح كل قرار لتتّخذ القرارات نفسها في مشروعك التالي.

شكل التطبيق

هذا هو الترتيب النهائي للملفات في مشروع Vite:

Text
src/
  main.jsx
  App.jsx               state, handlers, derived values
  storage.js            load and save to localStorage
  components/
    Header.jsx          title and "N to watch"
    AddMovieForm.jsx    controlled title input
    FilterBar.jsx       All / To watch / Watched
    MovieList.jsx       list or empty state
    MovieItem.jsx       one movie: toggle and remove

ملفّان فقط فيهما منطق يستحق الاختبار: App.jsx الذي يملك الـ state، وstorage.js. وكل component في components/ يستقبل props ويُرجع JSX. هذا هو التقسيم الذي تريده: طبقة رقيقة من الـ state والمنطق في الأعلى، وطبقة عريضة من القطع البسيطة تحتها.

التخزين، معزولًا

ابدأ بالـ module الذي يتحدّث مع المتصفّح:

src/storage.js
const KEY = 'watchlist.movies';

export function loadMovies() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY));
    return Array.isArray(saved) ? saved : [];
  } catch {
    return [];
  }
}

export function saveMovies(movies) {
  try {
    localStorage.setItem(KEY, JSON.stringify(movies));
  } catch {
    // Storage full or blocked: the app still works, it just won't remember.
  }
}

قد يرمي localStorage أخطاء (محظور في بعض أوضاع الخصوصية، أو امتلأت سعته)، وقد يحتوي على أي شيء، بما في ذلك بيانات من نسخة أقدم من تطبيقك. فحص Array.isArray والتقاط الأخطاء يعنيان أن القيمة المعطوبة تعطيك قائمة فارغة بدل شاشة بيضاء. ولا ترى الـ components أي شيء من هذا.

الـ App: المالك

src/App.jsx
import { useEffect, useState } from 'react';
import { loadMovies, saveMovies } from './storage.js';
import Header from './components/Header.jsx';
import AddMovieForm from './components/AddMovieForm.jsx';
import FilterBar from './components/FilterBar.jsx';
import MovieList from './components/MovieList.jsx';

export default function App() {
  const [movies, setMovies] = useState(loadMovies);
  const [filter, setFilter] = useState('all');

  useEffect(() => {
    saveMovies(movies);
  }, [movies]);

  const left = movies.filter((m) => !m.watched).length;
  const visible = movies.filter((m) =>
    filter === 'all' ? true : filter === 'watched' ? m.watched : !m.watched,
  );

  function handleAdd(title) {
    setMovies([...movies, { id: crypto.randomUUID(), title, watched: false }]);
  }

  function handleToggle(id) {
    setMovies(movies.map((m) => (m.id === id ? { ...m, watched: !m.watched } : m)));
  }

  function handleRemove(id) {
    setMovies(movies.filter((m) => m.id !== id));
  }

  return (
    <main className="app">
      <Header left={left} total={movies.length} />
      <AddMovieForm onAdd={handleAdd} />
      <FilterBar value={filter} onChange={setFilter} />
      <MovieList movies={visible} onToggle={handleToggle} onRemove={handleRemove} />
    </main>
  );
}

اقرأه من الأعلى إلى الأسفل وستعرف التطبيق كله. قطعتان من الـ state. effect واحد، للشيء الوحيد الموجود خارج React. قيمتان مشتقّتان. ثلاثة معالجات، كلٌّ منها سطر واحد، وكلٌّ منها إجراء مسمّى يستطيع المستخدم القيام به. ثم التخطيط. وحين يصل بلاغ خطأ («حذف فيلم يعيده بعد تحديث الصفحة»)، تعرف بالضبط أي الأسطر قد تكون معنية.

تأتي المعرّفات من crypto.randomUUID()، وتُنشأ مرة واحدة في handleAdd وتُخزَّن مع الفيلم، تمامًا كما أوصى درس الـ keys. وهي تعمل على localhost وعبر HTTPS، وهذا يغطّي التطوير وVercel.

القطع التي تحت

الـ components الخاصة بالقائمة والعنصر قصيرة لأنها لا تفعل سوى عرض الـ props وتمرير الأحداث:

src/components/MovieList.jsx
import MovieItem from './MovieItem.jsx';

export default function MovieList({ movies, onToggle, onRemove }) {
  if (movies.length === 0) {
    return <p className="empty">No movies here yet.</p>;
  }

  return (
    <ul className="movie-list">
      {movies.map((movie) => (
        <MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
}
src/components/MovieItem.jsx
export default function MovieItem({ movie, onToggle, onRemove }) {
  return (
    <li className={movie.watched ? 'movie watched' : 'movie'}>
      <span className="title">{movie.title}</span>
      <button aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>
        {movie.watched ? 'Watched' : 'Mark watched'}
      </button>
      <button aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>
        Remove
      </button>
    </li>
  );
}

أمّا AddMovieForm وFilterBar فهما النسختان اللتان بنيتهما في الدروس السابقة، وصار Header يستقبل الرقمين اللذين يعرضهما بدل القائمة كاملة. ويحصل زر Remove على aria-label يسمّي الفيلم، لأن مستخدم الـ screen reader الذي يتنقّل بمفتاح Tab بين عشرة أزرار «Remove» يحتاج إلى معرفة الزر الذي يقف عليه.

النقر على Mark watched في MovieItem يستدعي onToggle، فتُنفَّذ handleToggle في App. تُطلق setMovies إعادة render؛ فتنزل props جديدة إلى Header وMovieList، ويكتب effect الحفظ في localStorage. MovieItem onToggle(id) App setMovies(…) Re-render Header MovieList effect بعد الـ commit saveMovies(movies)
نقرة واحدة، من البداية إلى النهاية

لماذا تصمد هذه البنية

انظر إلى ما يحتاجه كل component ليُفحص. يحتاج MovieList إلى مصفوفة ودالتين. أعطه مصفوفة فارغة ويجب أن يعرض «No movies here yet.»؛ وأعطه فيلمين ويجب أن يرسم عنصرين. ويحتاج MovieItem إلى فيلم واحد، والنقر على Remove يجب أن يستدعي onRemove بمعرّف ذلك الفيلم. لا يحتاج أيٌّ منهما إلى localStorage، ولا إلى نموذج حقيقي، ولا إلى بقية التطبيق. هذا ما تعنيه «قطع صغيرة قابلة للاختبار» عمليًا: لكل قطعة بضعة مدخلات وبضعة مخرجات مرئية.

وحين تضيف اختبارات آلية لاحقًا (Vitest مع React Testing Library هو الثنائي الشائع لمشاريع Vite)، فهذه هي الاختبارات التي ستكتبها أولًا. وحتى قبل أن تكون لديك اختبارات، تؤتي البنية ثمارها: حين تبدو الحالة الفارغة خاطئة، تفتح ملفًا واحدًا من 10 أسطر، لا component من 300 سطر.

حين تكبر المعالجات

ثلاثة معالجات من سطر واحد سهلة المتابعة. لكن إن أضفت التعديل وإعادة الترتيب والتراجع، فسيمتلئ App بمنطق التحديث. هذه هي اللحظة التي تفكّر فيها في useReducer: إنه ينقل كل قواعد «كيف تتغيّر الأفلام» إلى دالة نقية واحدة، moviesReducer(movies, action)، يمكنك اختبارها من دون رسم أي شيء. لا تبدأ من هناك. ابدأ بـ useState، والجأ إلى reducer حين تحدّث عدّة معالجات الـ state نفسه بطرق مترابطة ويصبح الـ component صعب القراءة.

Watchlist يعمل. لكن شكله ليس مميّزًا بعد، وهذا موضوع الدرس التالي: تنسيقه باستخدام Tailwind CSS.

أهم النقاط

  • يحتفظ التطبيق المكتمل بقطعتين فقط من الـ state الحقيقي في App، هما الأفلام والفلتر، ويشتقّ كل ما عداهما أثناء الـ render.
  • كل التغييرات على الأفلام تحدث في عدد قليل من المعالجات المسمّاة داخل App، ويستدعيها الأبناء عبر الـ props.
  • الـ components العرضية الصغيرة التي لا تفعل سوى استقبال الـ props سهلة القراءة وإعادة الاستخدام والاختبار.
  • اعزل واجهات المتصفّح مثل localStorage في module صغير مع معالجة للأخطاء، لتبقى الـ components بسيطة.
  • امشِ في كل مسار استخدام بيدك، بما فيه تحديث الصفحة والحالات الفارغة والاستخدام بلوحة المفاتيح، قبل أن تعدّ العمل منتهيًا.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

توسّع Watchlist المكتمل بزر «Clear watched» يكون معطّلًا حين لا يوجد فيلم مُشاهَد، إلى جانب العدد والقائمة المصفّاة. كم استدعاءً لـ useState يحتاج App؟

السؤال 2 من 4

يعلّم المستخدم فيلمًا كمُشاهَد بالنقر على زرّه في MovieItem. بأيّ ترتيب تحدث الأشياء؟

السؤال 3 من 4

لماذا يضع الدرس loadMovies وsaveMovies في module منفصل اسمه storage.js؟

السؤال 4 من 4

بعد إضافة «Clear watched» صار في App معالجات للإضافة والتبديل والحذف ومسح المُشاهَد، وكلها تستدعي setMovies بمنطق مختلف. متى يستحق useReducer العناء؟

تدريب

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

اجمع تطبيق Watchlist

كل الـ components مكتوبة، لكن App غير موصول. اجعله يعمل:

  • احتفظ بـ الأفلام والفلتر ('all' و'to-watch' و'watched') في الـ state، بدءًا من INITIAL و'all'.
  • يعرض #count عدد الأفلام غير المُشاهَدة بالشكل N to watch.
  • الإضافة عبر النموذج تُلحق فيلمًا جديدًا غير مُشاهَد.
  • زر Mark watched يبدّل حالة الفيلم، وزر Remove يحذفه، وأزرار الفلترة تغيّر الأفلام التي يستقبلها MovieList.

غيّر App فقط. (الحفظ في localStorage مستبعد هنا: ساحة التجربة تحظر التخزين. في مشروع Vite الخاص بك، استخدم الـ module المسمّى storage.js من الدرس.)

import { useState } from 'react';

const INITIAL = [
  { id: 1, title: 'Arrival', watched: true },
  { id: 2, title: 'Past Lives', watched: false },
  { id: 3, title: 'Dune: Part Two', watched: false },
];
let nextId = 4; // in a real app: crypto.randomUUID()

function AddMovieForm({ onAdd }) {
  const [title, setTitle] = useState('');
  const trimmed = title.trim();

  function handleSubmit(e) {
    e.preventDefault();
    if (!trimmed) return;
    onAdd(trimmed);
    setTitle('');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input id="title" aria-label="Movie title" value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit" disabled={!trimmed}>Add</button>
    </form>
  );
}

function FilterBar({ value, onChange }) {
  const options = [['all', 'All'], ['to-watch', 'To watch'], ['watched', 'Watched']];
  return (
    <div className="filters">
      {options.map(([key, label]) => (
        <button key={key} aria-pressed={value === key} onClick={() => onChange(key)}>
          {label}
        </button>
      ))}
    </div>
  );
}

function MovieItem({ movie, onToggle, onRemove }) {
  return (
    <li>
      <span className="title">{movie.title}</span>
      <button className="toggle" aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>
        {movie.watched ? 'Watched' : 'Mark watched'}
      </button>
      <button className="remove" aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>
        Remove
      </button>
    </li>
  );
}

function MovieList({ movies, onToggle, onRemove }) {
  if (movies.length === 0) {
    return <p className="empty">No movies here yet.</p>;
  }
  return (
    <ul>
      {movies.map((movie) => (
        <MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
}

export default function App() {
  // Wire it up: state for movies and filter, derived values, and handlers.
  const movies = INITIAL;

  return (
    <main>
      <h1>Watchlist</h1>
      <p id="count">0 to watch</p>
      <AddMovieForm onAdd={() => {}} />
      <FilterBar value="all" onChange={() => {}} />
      <MovieList movies={movies} onToggle={() => {}} onRemove={() => {}} />
    </main>
  );
}
جارٍ تحميل المحرّر…

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

المعاينة

الـ console

(لا مخرجات)

الفحوص

  • يعرض “2 to watch” في البداية
  • إضافة “Paddington 2” تُلحقه بالقائمة وتحدّث العدد
  • زر Mark watched على Past Lives يحدّث زرّه والعدد
  • الفلتران Watched وTo watch يعرضان الأفلام الصحيحة
  • زر Remove يحذف فيلمًا ويحدّث العدد
  • حذف آخر فيلم ظاهر يعرض “No movies here yet.”