• בלוג
  • פיתרון חידת ריאקט מאתמול

פיתרון חידת ריאקט מאתמול

26/06/2021

אתמול פירסמתי פה חידה על ניהול פוקוס בריאקט. למרות שבדרך כלל אני אוהב להשאיר חידות כאלה פתוחות, הפעם שלחתם הרבה שאלות והצעות מעניינות לגבי אותה חידה שחשבתי שיהיה מועיל לסכם את עיקרי הדברים וגם להצביע על הבעיה המרכזית בקוד (ומספר בעיות משנה). אני מזכיר שזה היה הקוד המבלבל שמציג 4 תיבות וצריך היה להעביר את הפוקוס לתיבה הבאה אחרי כל לחיצת מקלדת בתיבה מסוימת, אבל לא ממש עושה את זה:

import "./styles.css";

import React, { useRef, useState } from "react";

export default function Targil1(props) {
  const inputCount = 4;

  const [inputsText, setInputsText] = useState(new Array(inputCount).fill(""));

  const inputsRef = [];

  for (let i = 0; i < inputCount; i++) {
    inputsRef.push(useRef(null));
  }

  const containerDiv = {
    display: "flex"
  };

  function ChangeValueAndFocus(index, e) {
    inputsText[index] += e.key;

    inputsRef[(index + 1) % inputCount].current.focus();

    setInputsText([...inputsText]);
  }

  return (
    <div style={containerDiv}>
      {inputsText.map((item, index) => (
        <input
          value={item}
          key={index}
          autoFocus={index === 0 ? true : false}
          ref={inputsRef[index]}
          onKeyPress={(e) => ChangeValueAndFocus(index, e)}
        />
      ))}
    </div>
  );
}

וזה בקודסנדבוקס: https://codesandbox.io/s/tender-curie-ux3g5?file=/src/App.js

1. שינויים קטנים שעובדים

נקודה מעניינת ראשונה לגבי הקוד והתקלה בו היא שהתקלה תלוית דפדפן. ב Firefox הכל עובד כמו שצריך והבאג מופיע רק בכרום.

נקודה מעניינת שניה היא שמדובר בבעיה של סדר אירועים. השינוי הקטן הבא בפונקציה ChangeValueAndFocus גורם לקוד לעבוד:

  function ChangeValueAndFocus(index, e) {
    inputsText[index] += e.key;

    setTimeout(function () {
      inputsRef[(index + 1) % inputCount].current.focus();
    }, 0);

    setInputsText([...inputsText]);
  }

כלומר הפונקציה כן נקראת, היא כן משנה את הפוקוס אבל משהו שקורה אחרי אותה פונקציה "מקלקל" לנו את השינוי.

2. בעיה ראשונה: הוק בלולאה

הרבה אנשים שמו לב לשתי בעיות בקוד רק מתוך קריאת השגיאות בקונסול ובקודסנדבוקס: בעיה ראשונה היתה הקריאה ל useRef בתוך לולאה, ובעיה שניה קשורה למאפיין value שמופיע באלמנט שאין לו onChange.

הבעיה הראשונה היא דווקא הפשוטה יותר להבנה ותיקון, וגם לא קשורה לבאג שלנו. זה נכון שבריאקט אסור להפעיל Hooks בתוך תנאים או לולאות, כדי לוודא שקריאות ל Hooks מופעלות בדיוק אותו מספר של פעמים ובאותו סדר. במקרה שלנו מנגנונים אוטומטיים יכולים להסתכל על הלולאה ולצעוק שהקריאה ל useRef בלולאה נוגדת את החוקים של ריאקט, אבל האמת שאנחנו יודעים יותר טוב מהם. שינוי קטן של הקוד ישאיר את ההתנהגות אבל ירגיע את הכלי האוטומטי:

export default function Targil1(props) {
  const inputCount = 4;

  const [inputsText, setInputsText] = useState(new Array(inputCount).fill(""));

  const inputsRef = [];

  inputsRef.push(useRef(null));
  inputsRef.push(useRef(null));
  inputsRef.push(useRef(null));
  inputsRef.push(useRef(null));

  // ...

אבל האמת שיש הרבה צדק בטענה של הכלים האוטומטיים. נכון שהלולאה כרגע מכילה 4 איטרציות אבל אי אפשר לדעת אם בעתיד המספר הזה לא יגיע מ prop, ואז הקוד באמת יתחיל להשתולל.

פיתרון טוב יותר יהיה לשכתב את הקוד כך שיהיה לנו useRef יחיד על האלמנט הראשי וממנו "נגיע" כל פעם לאלמנט ה input המתאים כדי לשנות את הפוקוס. זה אומר לכתוב קוד שנראה כך:

import "./styles.css";

import React, { useRef, useState } from "react";

export default function Targil1(props) {
  const inputCount = 4;
  const [inputsText, setInputsText] = useState(new Array(inputCount).fill(""));
  const ref = useRef(null);

  const containerDiv = {
    display: "flex"
  };

  function ChangeValueAndFocus(index, e) {
    inputsText[index] += e.key;
    const inputs = ref.current.querySelectorAll("input");
    inputs[(index + 1) % inputCount].focus();

    setInputsText([...inputsText]);
  }

  return (
    <div style={containerDiv} ref={ref}>
      {inputsText.map((item, index) => (
        <input
          value={item}
          key={index}
          autoFocus={index === 0 ? true : false}
          onKeyPress={(e) => ChangeValueAndFocus(index, e)}
        />
      ))}
    </div>
  );
}

שעדיין לא עובד בכרום.

3. בעיה מרכזית: ההבדל בין onKeyPress ל onChange

האמת שהבעיה המרכזית בקוד היא האזהרה השניה שקופצת מהקונסול:

proxyConsole.js:64 Warning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

ריאקט מזכיר לי שאלמנט קלט בטופס יכול להיות או Controllerd או Uncontolled, ומה שקובע מה יהיה סוג האלמנט זה האם יש לו את המאפיינים value ו onChange. אם המאפיינים קיימים זהו Controlled Input, ואם אינם קיימים הוא אלמנט קלט חופשי. ומה אם קיים רק אחד? פה מתחיל הבלאגן.

בגלל שקיים שדה value ריאקט ינסה לכתוב את התוכן מהמערך inputsText לתוך התיבות. אבל הבעיה שהתוכן מתעדכן באירוע הלא נכון: אירוע onKeyPress במקום אירוע onChange. צריך להגיד בשני האירועים ריאקט יסיים את הפעלת הפונקציה ואז יפעיל Render מחדש כי הפונקציה מעדכנת משתנה State. ההבדל הוא שלאירוע onKeyPress בדפדפן כרום יש פעולת ברירת מחדל שמחזירה את הפוקוס לאלמנט שעכשיו לחצו עליו. שימו לב לקוד הדוגמה הבא:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>A</title>
  </head>
  <body>
    <input id="b"/>
    <input id="a"/>

    <script>
      b.addEventListener("keypress", function(ev) {
        // ev.preventDefault();
        a.focus();
      });
    </script>

  </body>
</html>

הקוד (לא ריאקט, סתם JavaScript) מאפשר לנו להקליד איזה מילים שנרצה לתוך תיבת הטקסט ולא משנה פוקוס. אבל, אם נוציא מהערה את הקריאה ל ev.preventDefault() אז הפוקוס יקפוץ אחרי הלחיצה הראשונה והטקסט ייכתב לתיבה השניה בלבד.

התיקון הריאקטי לבעיה יהיה לעבור להשתמש ב onChange במקום ב onKeyPress בצורה הבאה:

import "./styles.css";

import React, { useRef, useState } from "react";

export default function Targil1(props) {
  const inputCount = 4;
  const [inputsText, setInputsText] = useState(new Array(inputCount).fill(""));
  const ref = useRef(null);

  const containerDiv = {
    display: "flex"
  };

  function ChangeValueAndFocus(index, e) {
    inputsText[index] += e.target.value;
    const inputs = ref.current.querySelectorAll("input");
    inputs[(index + 1) % inputCount].focus();
    console.log("Focus Changed!");

    setInputsText([...inputsText]);
  }

  console.count("Render!");

  return (
    <div style={containerDiv} ref={ref}>
      {inputsText.map((item, index) => (
        <input
          value={item}
          key={index}
          autoFocus={index === 0 ? true : false}
          onChange={(e) => ChangeValueAndFocus(index, e)}
        />
      ))}
    </div>
  );
}

זה עובד טוב וגם משמח את ריאקט. הודעות השגיאה על אלמנט שיש לו value אבל אין לו onChange נעלמות.

פיתרון שני שעובד הוא להישאר עם onKeyPress ופשוט להוסיף קריאה ל ev.preventDefault() בתוך קוד פונקציית הטיפול בלחיצה. זאת קריאה שהיינו צריכים גם בכתיבת קוד JavaScript רגיל, ואולי בגלל זה קל לפספס אותה ולדמיין שריאקט שומר עלינו מהשטויות של הדפדפן.

כל הכבוד לכל אלה מכם שניסו וכתבו לי ומקווה שאחרי פוסט הפיתרון יותר ברור למה אהבתי את השאלה.