פיתרון חידת ריאקט מאתמול
אתמול פירסמתי פה חידה על ניהול פוקוס בריאקט. למרות שבדרך כלל אני אוהב להשאיר חידות כאלה פתוחות, הפעם שלחתם הרבה שאלות והצעות מעניינות לגבי אותה חידה שחשבתי שיהיה מועיל לסכם את עיקרי הדברים וגם להצביע על הבעיה המרכזית בקוד (ומספר בעיות משנה). אני מזכיר שזה היה הקוד המבלבל שמציג 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 anonChange
handler. This will render a read-only field. If the field should be mutable usedefaultValue
. Otherwise, set eitheronChange
orreadOnly
.
ריאקט מזכיר לי שאלמנט קלט בטופס יכול להיות או 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 רגיל, ואולי בגלל זה קל לפספס אותה ולדמיין שריאקט שומר עלינו מהשטויות של הדפדפן.
כל הכבוד לכל אלה מכם שניסו וכתבו לי ומקווה שאחרי פוסט הפיתרון יותר ברור למה אהבתי את השאלה.