الاستجابة للمدخلات باستخدام الحالة

توفر React طريقة تصريحيّة (declarative) لتعديل واجهة المستخدم (UI). فبدلًا من تعديل أجزاء منفردة من واجهة المستخدم مباشرةً، يمكنك وصف الحالات المختلفة التي يأخذها مكوّنك، والانتقال بينهم كاستجابة لمدخلات المستخدم. هذا مشابه لتصوّر المصمّمين عن واجهة المستخدم.

You will learn

  • كيف تختلف البرمجة التصريحيّة لواجهة المستخدم (declarative UI programming) عن البرمجة الأمريّة لواجهة المستخدم (imperative UI programming)
  • كيفية استعراض الحالات المرئية المختلفة التي يأخذها مكوّنك
  • كيفية تنشيط التغييرات بين الحالات المرئية المختلفة من خلال الكود

كيف تُقارَن واجهة المستخدم التصريحيّة (declarative UI) بالأمريّة (imperative)

عندما تصمم تعاملات واجهة المستخدم، عليك غالبًا التفكير في كيفية تغيّر واجهة المستخدم كاستجابة لإجراءات المستخدم. فكر في نموذج يسمح للمستخدم بإرسال إجابة:

  • عندما تكتب شيئًا داخل النموذج، يصبح الزر “أرسل” مفعلًا.
  • عندما تضغط على “أرسل”، يصبح كلٌ من النموذج والزر معطلا ويظهر مؤشر التحميل.
  • لو نجح طلب الشبكة، يبدأ النموج بالاختفاء، وتظهر رسالة “شكرًا لك”.
  • لو فشل طلب الشبكة، تظهر رسالة خطأٍ، ويصبح النموذج مفعلًا مجددا.

في البرمجة الأمرية (imperative programming)، يتوافق ما ذُكر أعلاه مباشرة مع طريقة تطبيق التعاملات. عليك أن تكتب التعليمات التامّة لتعديل واجهة المستخدم معتمدًا على ما حصل للتوّ. إليك طريقة أخرى لتفكر في هذا الأمر: تخيل نفسك راكبًا إلى جانب أحدهم في سيارة مع إخباره في كل منعطف تلو الآخر عن وجهة الذهاب.

في سيارة يقودها شخص يبدو عليه القلق يمثل جافا سكريبت، يأمر أحد الركاب السائق بتنفيذ سلسلة من المنعطفات المعقدة للتنقل.

Illustrated by Rachel Lee Nabors

هو لا يعلم إلى أين تريد أن تذهب، هو يتبع أوامرك فقط. (ولو أنك أعطيته الاتجاهات الخاطئة، سوف ينتهي بك المطاف لوجهة خاطئة!) هذا يطلق عليه أمري (imperative) لأن عليك أن “تأمر” كل عنصر، بداية من مؤشر التحميل إلى الزر، مخبرًا الكمبيوتر عن كيفية تحديث واجهة المستخدم (UI).

في هذا المثال للبرمجة الأمريّة لواجهة المستخدم، النموذج مبنيّ بدون React. باستخدام DOM الخاص بالمتصفح فقط:

async function handleFormSubmit(e) {
  e.preventDefault();
  disable(textarea);
  disable(button);
  show(loadingMessage);
  hide(errorMessage);
  try {
    await submitForm(textarea.value);
    show(successMessage);
    hide(form);
  } catch (err) {
    show(errorMessage);
    errorMessage.textContent = err.message;
  } finally {
    hide(loadingMessage);
    enable(textarea);
    enable(button);
  }
}

function handleTextareaChange() {
  if (textarea.value.length === 0) {
    disable(button);
  } else {
    enable(button);
  }
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

function enable(el) {
  el.disabled = false;
}

function disable(el) {
  el.disabled = true;
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() == 'istanbul') {
        resolve();
      } else {
        reject(new Error('توقع جيد ولكن إجابة خاطئة. حاول مرة أخرى!'));
      }
    }, 1500);
  });
}

let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;

تعديل واجهة المستخدم (UI) أمريًّا يعمل بشكل جيد كفاية للأمثلة المعزولة، ولكنه يصبح أكثر صعوبة بشكل استثنائي عندما تدير أنظمة أكثر تعقيدًا. تخيل تحديث صفحة مليئة بالنماذج المختلفة مثل هذه هنا. إضافة عنصر واجهة مستخدم جديد سيتطلب فحص كامل الكود الموجود بحرص للتأكد من أنك لم تقم بعمل خطأ (على سبيل المثال، نسيان إظهار أو إخفاء شيء ما).

صُنعت React لحل هذه المشكلة.

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

في سيارة يقودها React، يطلب أحد الركاب نقله إلى مكان محدد على الخريطة. تكتشف React كيفية القيام بذلك.

Illustrated by Rachel Lee Nabors

التفكير في واجهة المستخدم (UI) تصريحيًا

لقد رأيت كيفية تنفيذ نموذج أمريَّا أعلاه. لفهم أفضل لكيفية التفكير في React، سوف تمر بإعادة تنفيذ واجهة المستخدم (UI) هذه باستخدام React أدناه:

  1. عيّن الحالات المرئية المختلفة لمكوّنك
  2. حدد ما ينشط تغييرات تلك الحالات
  3. مثّل الحالة في الذاكرة باستخدام useState
  4. احذف أيّ متغيرات حالة غير ضرورية
  5. اربط معالجات الأحداث لتعيين الحالة

الخطوة 1: عيّن الحالات المرئية المختلفة لمكوّنك

في علوم الحاسب، ربما تسمع عن “آلة الحالة (state machine)” كونها واحدة من ضمن “حالات” متعددة. إذا كنت تعمل مع مصمم، لربما رأيت نماذج تجريبية لـ”الحالات المرئية” المختلفة

أولًا، أنت تحتاج لتصوّر جميع “الحالات” المختلفة لواجهة المستخدم (UI) التي قد يراها المستخدم:

  • فارغة: النموذج يحتوي على زر “إرسال” معطل.
  • كتابة: النموذج يحتوي على زر “إرسال” مفعّل.
  • إرسال: النموذج معطل تمامًا. يتم عرض مؤشر التحميل.
  • نجاح: يتم عرض رسالة “شكرًا لك” بدلًا من النموذج.
  • خطأ: مثل حالة الكتابة، ولكن مع رسالة خطأ إضافية

تمامًا مثل المصمم، سترغب في “تجربة” أو إنشاء “نماذج تجريبية” للحالات المختلفة قبل أن تضيف المنطق. على سبيل المثال، ها هو نموذج تجريبي للجزء المرئي فقط من النموذج. هذا النموذج التجريبي متحكم به بواسطة خاصيّة تدعى status مع قيمة افتراضية 'empty':

export default function Form({
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>هذا صحيح!</h1>
  }
  return (
    <>
      <h2>اختبار المدينة</h2>
      <p>
        في أي مدينة يوجد لوحة إعلانية تقوم بتحويل الهواء لمياه صالحة للشرب؟
      </p>
      <form>
        <textarea />
        <br />
        <button>
          أرسل
        </button>
      </form>
    </>
  )
}

يمكنك تسمية الخاصيّة أيّ شيء تريد، التسمية ليست مهمة. جرب تعديل status = 'empty' إلى status = 'success' لترى رسالة النجاح تظهر. التجربة تتيح لك التكرار السريع على واجهة المستخدم قبل ربط أي منطق. ها هو نموذج تجريبي أكثر تفصيلًا لنفس المكوّن، يظل “متحكمًا به” بواسطة الخاصية status:

export default function Form({
  // جرب 'submitting'، 'error'، 'success':
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>هذا صحيح!</h1>
  }
  return (
    <>
      <h2>City quiz</h2>
      <p>
        في أي مدينة يوجد لوحة إعلانية تقوم بتحويل الهواء لمياه صالحة للشرب؟
      </p>
      <form>
        <textarea disabled={
          status === 'submitting'
        } />
        <br />
        <button disabled={
          status === 'empty' ||
          status === 'submitting'
        }>
          أرسل
        </button>
        {status === 'error' &&
          <p className="Error">
            توقع جيد ولكن إجابة خاطئة. حاول مرة أخرى!
          </p>
        }
      </form>
      </>
  );
}

Deep Dive

عرض عديد من الحالات المرئية مرة واحدة

لو أن لمكون العديد من الحالات المرئية، قد يكون ملائمًا عرضها جميعها في صفحة واحدة:

import Form from './Form.js';

let statuses = [
  'empty',
  'typing',
  'submitting',
  'success',
  'error',
];

export default function App() {
  return (
    <>
      {statuses.map(status => (
        <section key={status}>
          <h4>نموذج ({status}):</h4>
          <Form status={status} />
        </section>
      ))}
    </>
  );
}

صفحات مثل هذه غالبًا يطلق عليها “living styleguides” أو “storybooks”

الخطوة 2: حدد ما ينشط تغييرات تلك الحالة

يمكنك تنشيط تحديثات الحالة كاستجابة إلى نوعين من المدخلات:

  • مدخلات الإنسان، مثل الضغط على زر، أو الكتابة في حقل، أو زيارة رابط.
  • مدخلات الكمبيوتر، مثل وصول رد الشبكة، أو استكمال المؤقت، أو تحميل الصورة.
إصبع
مدخلات الإنسان
آحاد وأصفار
مدخلات الكمبيوتر

Illustrated by Rachel Lee Nabors

في كلتا الحالتين، يجب عليك تعيين متغيرات الحالة (state variables) لتُحدّث واجهة المستخدم (UI). من أجل تطوير النموذج سوف تحتاج لتغيير الحالة كنتيجة لبعض من المدخلات المختلفة:

  • تغيّر حقل إدخال النص (الإنسان) سوف يغيرها من الحالة الفارغة إلى حالة الكتابة أو العكس، يعتمد على ما إذا كان حقل النص فارغًا أم لا.
  • الضغط على زر الإرسال (الإنسان) سوف يغيرها إلى حالة الإرسال.
  • استجابة ناجحة للشبكة (الكمبيوتر) سوف يغيرها إلى حالة النجاح.
  • استجابة فاشلة للشبكة (الكمبيوتر) سوف يغيرها إلى حالة الخطأ مع رسالة الخطأ المناسبة.

Note

لاحظ أن مدخلات الإنسان غالبًا تتطلب معالجات أحداث (event handlers)!

للمساعدة على تصوّر هذا التدفق، جرّب رسم كل حالة على ورقة كدائرة مُعنّوَنة. وكل تغيّر بين حالتين كسهم. تستطيع رسم العديد من التدفقات بهذه الطريقة وحل الأخطاء مبكرًا قبل التنفيذ.

مخطط التدفق يتحرك من اليسار إلى اليمين مع 5 عناصر. العقدة الأولى المسماة 'فارغة' لها حافة واحدة تسمى 'بدء الكتابة' متصلة بالعقدة المسماة 'الكتابة'. تحتوي هذه العقدة على حافة واحدة تسمى 'اضغط إرسال' متصلة بعقدة تسمى 'إرسال'، والتي لها حافتان. الحافة'اليسرى تحمل علامة 'خطأ في الشبكة' وهي متصلة بعقدة تحمل اسم 'خطأ'. الحافة اليمنى تحمل علامة 'نجاح الشبكة' وتتصل بعقدة تحمل اسم 'النجاح'.
مخطط التدفق يتحرك من اليسار إلى اليمين مع 5 عناصر. العقدة الأولى المسماة 'فارغة' لها حافة واحدة تسمى 'بدء الكتابة' متصلة بالعقدة المسماة 'الكتابة'. تحتوي هذه العقدة على حافة واحدة تسمى 'اضغط إرسال' متصلة بعقدة تسمى 'إرسال'، والتي لها حافتان. الحافة'اليسرى تحمل علامة 'خطأ في الشبكة' وهي متصلة بعقدة تحمل اسم 'خطأ'. الحافة اليمنى تحمل علامة 'نجاح الشبكة' وتتصل بعقدة تحمل اسم 'النجاح'.

حالات النموذج

الخطوة 3: مثّل الحالة في الذاكرة باستخدام useState

بعد ذلك ستحتاج لتمثّل الحالات المرئية لمكوّنك في الذاكرة باستخدام useState. البساطة هي المفتاح: كل قطعة من الحالة هي “قطعة متحركة”، وبالـتأكيد تريد تقليل “القطع المتحركة” قدر الإمكان. ;كثرة التعقيدات تؤدي إلى كثرة الأخطاء!

ابدأ بالحالة التي لا بدّ من وجودها. على سبيل المثال، سوف تحتاج لتخزين الإجابة answer للمدخل، والخطأ error (لو وُجد) لتخزين آخر خطأ:

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);

بعد ذلك، سوف تحتاج لمتغير حالة يمثّل أي من الحالات المرئية التي تريد عرضها. يوجد غالبًا أكثر من طريقة واحدة لتمثيل ذلك في الذاكرة، لذلك سوف يتعين عليك تجريبها.

إذا واجهت صعوبة في التفكير في أفضل طريقة على الفور، ابدا بإضافة حالة كافية حتى تكون متأكدًا تمامًا من تغطية جميع الحالات المرئية المحتملة:

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

خطوتك المبدئية على الأرجح لن تكون هي الأفضل، ولكن هذا لا بأس به — إعادة تصميم الحالة هو جزء من العملية!

الخطوة 4: احذف أيّ متغيرات حالة غير ضرورية

ما تريده هو تجنب تكرار محتوى الحالة لذلك أنت تقوم بتعقب ما هو ضروري فقط. قضاء قليل من الوقت في إعادة تصميم هيكل حالتك سوف يجعل مكوّنك أسهل للفهم، يقلل التكرار، ويتجنب المعاني غير المقصودة. هدفك هو منع الأوضاع التي تكون بها الحالة في الذاكرة لا تمثل أي واجهة مستخدم صالحة من التي تود للمستخدم أن يراها. (على سبيل المثال، لن تريد أبدًا إظهار رسالة خطأ مع تعطيل الإدخال في نفس الوقت، أو أن المستخدم لن يكون قادرًا على تصحيح الخطأ!)

هنا بعض الاسئلة التي يمكن أن تسألها عن متغيرات الحالة:

  • هل هذه الحالة تسبب معضلة؟ على سبيل المثال، isTyping و isSubmitting لا يمكن لكليهما أن يكونا بقيمة true. المعضلة غالبًا تعني أن الحالة ليست مقيدة بالشكل الكافي. هناك أربع احتمالات ممكنة لقيميتين منطقيتين (boolean). لكن ثلاث منهن فقط يوافقن حالات صالحة. لحذف الحالة “المستحيلة”، يمكنك جمع تلك الحالات داخل status التي يجب أن تكون واحدة من ثلاث قيم: 'typing', 'submitting', أو 'success'.
  • هل نفس المعلومات متاحة بالفعل لمتغير حالة آخر؟ معضلة أخرى: isEmpty و isTyping لا يمكنها أن يكونا true في نفس الوقت. بجعلهما متغيرين حالة منفصلين، تخاطر بفقدان الترابط بينهما وإحداث الأخطاء. لحسن الحظ، يمكن حذف isEmpty والتحقق من answer.length === 0 بدلًا عن ذلك.
  • هل يمكنك الحصول على نفس المعلومات من عكس متغير حالة آخر؟ isError غير ضروري لأنه يمكنك التحقق من error !== null بدلًا عن ذلك.

بعد هذا التبسيط، تبقى لديك 3 (من أصل 7!) متغيرات حالة ضرورية:

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

أنت تعلم أنها ضرورية، لأنك لا تستطيع إزالة أيّ منها بدون تخريب آلية العمل.

Deep Dive

إزالة الحالات “المستحيلة” باستخدام مخفض (reducer)

هذه الثلاث متغيرات تمثيل جيد كفاية لحالة النموذج. مع ذلك، لا تزال هناك بعض الحالات المتوسطة الغير منطقية بشكل كافٍ. على سبيل المثال، error التي لا تحمل القيمة null غير منطقية عندما تكون status تحمل قيمة success. لتمثيل الحالة بطريقة أكثر دقة، يمكنك استخلاصها إلى مخفض. المخفضات تتيح ليك توحيد العديد من متغيرات الحالة داخل كائن (object) واحد وتجميع كل المنطق المتعلق بها.

الخطوة 5: اربط معالجات الأحداث لتعيين الحالة

أخيرًا، إنشاء معالجات الأحداث التي تحدّث الحالة. أدناه هو النموذج النهائي، مع كل معالجات الأحداث متصلة ببعضها:

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>اختبار المدينة</h2>
      <p>
        في أي مدينة يوجد لوحة إعلانية تقوم بتحويل الهواء إلى مياه صالحة للشرب؟
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Submit
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  // محاكاة للتواصل باستخدام الشبكة.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima'
      if (shouldError) {
        reject(new Error('توقع جيد ولكن إجابة خاطئة. حاول مرة أخرى!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

بالرغم من أن هذا الكود أطول من المثال الأمريّ الأصلي، إلا أنه أقل هشاشة بكثير. التعبير عن جميع الأوامر على أنها تغيّرات في الحالة يتيح لك لاحقًا إضافة حالات مرئية جديدة دون تعطيل الحالات القائمة بالفعل. كما يتيح لك أيضًا تغيير ما يجب عرضه في كل حالة دون تغيير منطق الأمر نفسه.

Recap

  • البرمجة التصريحية تعني وصف واجهة المستخدم لكل حالة مرئية عوضًا عن الإدارة التفصيلية لواجهة المستخدم (الأمريّة).
  • عند تطوير مكوّن:
    1. حدد كل حالاته المرئية.
    2. عيّن المنشطات الوادة الإنسان والكمبيوتر لتغيّرات الحالة.
    3. مثل الحالة عن طريق useState.
    4. احذف الحالة غير الضرورية لتجنب الأخطاء والمعضلات.
    5. اربط معالجات الأحداث لتعيين الحالة.

Challenge 1 of 3:
إضافة وحذف صنف (class) CSS

نفذ ذلك بحيث يكون النقر على الصورة يحذف صنف CSS background--activeمن الـ<div> الخارجي، لكن يضيف الصنف picture--active لـ<img>. النقر على الخلفية مجددًا يجب أن يعيد أصناف CSS الأصلية.

عمليًا، يمكنك توقع أن النقر على الصورة يحذف الخلفية البنفسجية ويقوم بتحديد (highlight) إطار الصورة. النقر خارج الصورة يقوم بتحديد الخلفيةـ ولكن يحذف تحديد إطار الصورة.

export default function Picture() {
  return (
    <div className="background background--active">
      <img
        className="picture"
        alt="Rainbow houses in Kampung Pelangi, Indonesia"
        src="https://i.imgur.com/5qwVYb1.jpeg"
      />
    </div>
  );
}