הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

ריפקטורינג טוב יותר מ Mock-ים

03/01/2024

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

def main():
    secret_number = random.randint(1, 100)
    while True:
        next_guess = int(input("Next guess: "))
        if next_guess == secret_number:
            print("Bravo! You guessed it")
            break
        elif next_guess < secret_number:
            print("Sorry, too small")
        else:
            print("Sorry, too large")

כל מה שצריך בשביל לבדוק את הקוד הוא לכתוב כמה Mock-ים, למשל לדרוס את input ואת random.randint ואז להסתכל מה הודפס. למשל הבדיקה הבאה מוודאת שכשמשתמש מנחש את המספר התוכנית תדפיס את ההודעה הנכונה ותצא מהלולאה:

def test_stop_when_guess_is_correct(capsys):
    with patch('builtins.input', new=lambda _: "10\n"):
        with patch('random.randint') as randint:
            randint.return_value = 10
            main()
            assert capsys.readouterr()[0] == "Bravo! You guessed it\n"

זה נהיה קצת יותר מסובך כשרוצים לוודא את התשובה של המשחק לניחוש גבוה מדי:

def test_says_too_small_when_guessing_small_number(capsys):
    with patch('builtins.input') as fake_input:
        with patch('random.randint') as randint:
            fake_input.side_effect = ["10\n"]
            randint.return_value = 30
            try:
                main()
            except StopIteration:
                pass
            assert capsys.readouterr()[0] == "Sorry, too small\n"

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

עכשיו ננסה תרגיל אחר והוא לארגן מחדש את הקוד כדי שלא נצטרך Mock-ים. בדיקות טובות עשויות להיראות כך:

def test_equal():
    assert game.check_user_input(20, "20\n") == game.TEXT['=']


def test_too_large():
    assert game.check_user_input(20, "50\n") == game.TEXT['>']


def test_too_small():
    assert game.check_user_input(20, "10\n") == game.TEXT['<']


def test_stop_when_equal(capsys):
    game.game(50, ["20\n", "40\n", "50\n", "80\n"])
    assert capsys.readouterr()[0] == f"""{game.TEXT['<']}
{game.TEXT['<']}
{game.TEXT['=']}
"""

נשים לב שכל בדיקה הפעם בודקת בדיוק את מה שהיא צריכה בלי למלא את הפונקציה ב Boilerplate של דריסות. והקוד עצמו? הנה הגירסה החדשה שעושה בדיוק את אותו דבר ומתאימה לקוד הבדיקות החדש:

from typing import Callable
import random
import itertools

TEXT = {
    "=": "Bravo! You guessed it",
    "<": "Sorry, too small",
    ">": "Sorry, too large"
}

MIN = 1
MAX = 100


def check_user_input(secret_number: int, user_value: str) -> str:
    if int(user_value) == secret_number:
        return TEXT['=']
    elif int(user_value) < secret_number:
        return TEXT['<']
    else:
        return TEXT['>']


def input_stream():
    while True:
        yield input("Next Guess: ")

def secret_number_generator():
    return random.randint(MIN, MAX)


def game(secret_number, inputs):
    responses = (check_user_input(secret_number, i) for i in inputs)
    for response in responses:
        print(response)
        if response == TEXT['=']:
            break


if __name__ == "__main__":
    game(secret_number_generator(), input_stream())

ונכון זה קוד של 41 שורות במקום 18. ואני מכיר גם את הטיעון שקל יותר לתחזק קוד קצר מאשר קוד ארוך, והוא נכון במידה מסוימת. הבעיה שאחרי שהקוד עובר אורך מסוים הוא כבר לא נחשב "קצר". קוד של 20,000 שורות עם בדיקות וחלוקה נכונה לפונקציות הוא יותר קל לתחזוקה מאשר קוד של 10,000 שורות מבולגנות.

אנחנו ב 2024 ואני עדיין צריך לשבור את השיניים בשביל לעבוד עם מספרים בסקאלה

02/01/2024

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

הקוד של הקיטור היום מתחיל עם רעיון פשוט שנקרא Range:

scala> 10.until(20)
val res0: Range = Range 10 until 20

עם Range אפשר לעשות המון דברים למשל לגלות מה הסכום:

scala> 10.until(20).sum
val res2: Int = 145

עכשיו ננסה את אותו דבר עם מספרים גדולים:

scala> 2728902838.until(4728902838)
-- Error: ----------------------------------------------------------------------
1 |2728902838.until(4728902838)
  |^^^^^^^^^^
  |number too large
-- Error: ----------------------------------------------------------------------
1 |2728902838.until(4728902838)
  |                 ^^^^^^^^^^
  |                 number too large

נו ברור שאי אפשר לכתוב מספר גדול בתור ליטרל, אז אני מוסיף לו l קטנה כדי שיהיה Long, ואז מקבל הודעת שגיאה אחרת:

scala> 2728902838.until(4728902838l)
-- Error: ----------------------------------------------------------------------
1 |2728902838.until(4728902838l)
  |^^^^^^^^^^
  |number too large
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |2728902838.until(4728902838l)
  |                 ^^^^^^^^^^^
  |                 Found:    (4728902838L : Long)
  |                 Required: Int
  |
  | longer explanation available when compiling with `-explain`

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

scala> 2728902838l.until(4728902838l)
val res4: scala.collection.immutable.NumericRange.Exclusive[Long] = NumericRange 2728902838 until 4728902838

עובד? לא בדיוק. נשים לב שפקודת until של Int-ים החזירה משהו מטיפוס אחד ו until של Long-ים החזירה משהו מטיפוס אחר. ולמה זה מפריע? נמשיך לניסוי הבא עם רשימות:

scala> val l = List(a, b)
val l:
  List[scala.collection.immutable.AbstractSeq[Long | Int] &
    IndexedSeq[Long | Int]] = List(NumericRange 2728902838 until 4728902838, Range 10 until 20)

scala> a.start
val res0: Long = 2728902838

scala> l.head.start
-- [E008] Not Found Error: -----------------------------------------------------
1 |l.head.start
  |^^^^^^^^^^^^
  |value start is not a member of scala.collection.immutable.AbstractSeq[Long | Int] & IndexedSeq[Long | Int] - did you mean scala.collection.immutable.AbstractSeq[Long | Int] & IndexedSeq[Long | Int].last?
1 error found

למרות שגם ל a וגם ל b יש פונקציה start, ברגע שאני שם אותם יחד ברשימה אני מקבל רשימה של דברים יותר כלליים ולהם אין את הפונקציה start, ובאופן כללי אין יחסי ירושה בין שני סוגי הטווחים. וכן אני כנראה יכול להשתמש ב Structural Types בשביל לעקוף את הסיפור הזה אבל צריך לזכור מאיפה התחלנו - כל מה שרציתי היה לקבל פונקציות בסיסיות של טווח על מספרים שיעבדו אותו דבר למספרים גדולים וקטנים.

שורה תחתונה המאזן בין Int ל Long הוא בין יעילות (של Int) לגנריות (של Long). אנחנו ב 2024 והגיע הזמן ש Long תהיה ברירת המחדל.

עדיין קשה

01/01/2024

חבר שואל - אני יוצא לריצה פעם בשבוע כבר כמה חודשים ועדיין הריצות קשות לי ואני לא מרגיש שיפור, כל ריצה מרגישה כמו הפעם הראשונה. מה עושים?

ופה יש רק שתי תשובות אפשריות:

  1. יש לך בעיה גנטית נדירה שבגללה אתה לא יכול להיכנס לכושר. מה שלא תעשה תמיד תישאר בנקודת ההתחלה. חבל להתאמץ עדיף למצוא תחביבים אחרים.

או

  1. כנראה שריצה פעם בשבוע זה לא מספיק בשבילך בשביל להיכנס לכושר. אולי לאנשים מסוימים זה עובד אבל אתה צריך לנסות דרך אחרת, אולי לרוץ 3 פעמים בשבוע, אולי לשלב אימונים מסוגים אחרים, אולי לשנות תזונה. שווה להתמיד ולהמשיך לנסות עד שתמצא את הדרך להתקדם.

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

עד שכן צריך

31/12/2023

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

Eventually, they looked to Bloom Filters, further combined with Count-Min Sketch data structures, which together offered an effective way of supporting multi-field filter queries.

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

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

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

דרישות משרה גמישות

30/12/2023

אני מחפש עכשיו מתכנת/ת שיעזרו לי בפיתוח והדגש הוא על ניסיון בריילס (אתם כאלה? מכירים כאלה? אם כן דברו איתי). בכל מקרה שמתי מודעה בלינקדאין וכצפוי אפשר לחלק את התגובות שהגיעו לשלושה סוגים - אלה שמתאימים בול לתפקיד, אלה שממש לא קשורים, ואלה שבגדול יש התאמה אבל משהו חסר בפרופיל שלהם. פה אני רוצה לדבר על הקבוצה השלישית.

כשיש משרה שאנחנו רוצים זה די נפוץ שדרישות המשרה לא תואמות 100% את הכישורים שלנו. אולי הם מחפשים ניסיון בבסיס נתונים של מייקרוסופט ואני מכיר פוסטגרס. אולי בפרויקט ריאקט שלהם עובדים עם Recoil ולי יש ניסיון רק ברידאקס. אולי דורשים ניסיון בריילס 6 ואני עבדתי עם ריילס רק עד גירסה 4. אלה אי תאימויות שהן לא קריטיות, אבל כן צריך להתייחס אליהן כשאנחנו מגישים מועמדות כדי לעבור מקבוצת ה"אולי" לקבוצת ה"התאמה טובה".

אלה הדברים שהייתי עושה לפי סדר חשיבות (הטוב ביותר למעלה):

  1. (הכי טוב) - צרפו לבקשה שלכם פרויקט צד שעשיתם שמתאים לטכנולוגיה בה מגייסים (אין לכם פרויקט צד כזה? אולי זה זמן טוב לבנות אותו). כשאפשר לראות קוד הרבה פחות חשוב לספור שנות ניסיון.

  2. צרפו לבקשה מכתב מלווה (או סתם הודעה באימייל) שמסביר שאתם מודעים לפער ותסבירו מה אתם מתכננים לעשות כדי לסגור אותו. אם אפשר צרפו פרויקט בטכנולוגיה שהיא לא בדיוק מה שמחפשים אבל עדיין קרובה.

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

הכי חשוב להסתכל על המגייסים בתור אנשים שבאמת רוצים לתת לכם צ'אנס. במקרים שיש התאמה שלא מספיק ברורה מקורות החיים, תנו למגייסים את המידע שהם צריכים כדי לגשר על הפער.

טיפ רשתות - מקשיבים לפורט 0

29/12/2023

מה אתם עושים כשאתם צריכים להעלות שרת לבדיקות אבל לא יודעים באיזה פורט לבחור? במקום להסתבך ולחפש תנו למערכת ההפעלה להחליט. פורט 0 הוא לא פורט חוקי בתקשורת TCP, ולכן כשאנחנו מבקשים מהקוד להקשיב לפורט 0 אנחנו שולחים אותו לבקש איזשהו פורט פנוי ממערכת ההפעלה. וזה נפלא לבדיקות.

שימו לב לקוד הבא ב node.js שמעלה שרת echo, מנסה לכתוב אליו ומוודא שקיבל בחזרה את התשובה הנכונה:

const net = require('node:net');
const assert = require('node:assert');


async function main() {
  var server = net.createServer(function(socket) {
    socket.pipe(socket);
  });

  server.listen(0, '127.0.0.1');
  await new Promise((resolve, _reject) => server.on('listening', resolve));

  const clientSocket = net.createConnection(server.address().port);
  await new Promise((resolve, _reject) => clientSocket.on('connect', resolve));

  clientSocket.on('data', (data) => {
    const dataWritten = Buffer.from('hello world\n', 'utf8');
    const dataRead = data;

    assert(dataRead.equals(dataWritten));
    clientSocket.end();
    server.close();
  });

  clientSocket.write("hello world\n");
}

main();

התוכנית פותחת שרת על המחשב המקומי, מתחברת אליו, שולחת הודעת טקסט ומוודאת שקיבלה בחזרה בדיוק את אותה הודעה. מה שמעניין כאן הוא שורת ה listen:

server.listen(0, '127.0.0.1');

במקום להתחייב על פורט אני נותן למחשב לבחור, ואחר כך בשביל להתחבר אליו אני רק צריך להסתכל ב server ולראות מה הפורט שנבחר.

איך נולד באג (או: על החשיבות של בדיקות כשמשנים קוד)

28/12/2023

נתבונן בקוד הבא ב node.js:

import net from 'node:net';

const host = "localhost";
const port = 1234;

const socket = net.createConnection(port, host, () => {
  socket.write("hello\n");
  socket.end();
});

socket.on('error', () => {
  console.log(`Error connecting to ${host}:${port}`);
});

הקוד מנסה להתחבר לפורט 1234 על המכונה המקומית, לשלוח הודעה hello ואז לסגור את החיבור. אם יש לי מישהו שמקשיב על הפורט הכל יעבוד, ואם אף אחד לא מקשיב נגיע לאירוע error והודעת השגיאה תודפס על המסך. עד פה הכל טוב.

עכשיו נדמיין דרישה חדשה למערכת - יש לאפשר לקבוע את הפורט מבחוץ באמצעות משתנה סביבה. אם לא הוגדר ערך במשתנה הסביבה נשתמש עדיין ב 1234 בתור ברירת מחדל. הקוד המעודכן עשוי להיראות כך:

import net from 'node:net';

const host = "localhost";
const port = Number(process.env["PORT"]) || 1234;

const socket = net.createConnection(port, host, () => {
  socket.write("hello\n");
  socket.end();
});

socket.on('error', () => {
  console.log(`Error connecting to ${host}:${port}`);
});

נראה טוב? תחשבו שוב. ב JavaScript הפונקציה Number מחזירה מספר, אבל מספר יכול להיות מאוד גדול או מאוד קטן או אפילו כזה שמכיל נקודה עשרונית. בלי לשים לב איפשרנו למערכת לקבל מספרי פורט לא תקניים, מה שיגרום ל createConnection להיכשל לפני שמגיע לאירוע error. זה נראה ככה:

$ node demo.mjs
Error connecting to localhost:1234

$ PORT=8080 node demo.mjs
Error connecting to localhost:8080

$ PORT=abc node demo.mjs
Error connecting to localhost:1234

$ PORT=-1 node demo.mjs
node:internal/errors:496
    ErrorCaptureStackTrace(err);
    ^

RangeError [ERR_SOCKET_BAD_PORT]: Port should be >= 0 and < 65536. Received type number (-1).
    at new NodeError (node:internal/errors:405:5)
    at validatePort (node:internal/validators:409:11)
    at lookupAndConnect (node:net:1289:5)
    at Socket.connect (node:net:1246:5)
    at Object.connect (node:net:236:17)
    at file:///Users/ynonp/tmp/blog/nodebug/demo.mjs:6:20
    at ModuleJob.run (node:internal/modules/esm/module_job:217:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:308:24)
    at async loadESM (node:internal/process/esm_loader:42:7)
    at async handleMainPromise (node:internal/modules/run_main:66:12) {
  code: 'ERR_SOCKET_BAD_PORT'
}

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

הרבה פעמים אנחנו מדברים בבדיקות על Code Coverage בתור מדד שעוזר לנו להבין אם בדקנו כמו שצריך את המערכת. כיסוי קוד טוב אומר שכל הנתיבים נבדקו. אבל יותר נכון להסתכל על "כיסוי קלט", כלומר האם הבדיקות שלנו כיסו מספיק קלטים שונים שמייצגים את ההסתעפויות אליהן נגיע. דרך אחת להמציא קלטים טובים לבדיקה היא להיעזר ב Chat GPT. אלה האופציות שהוא נתן כשביקשתי ממנו ערכים אפשריים למשתנה PORT בתוכנית כדי לבדוק שהכל עובד:

# Test with default port
PORT=1234 node your_program.js

# Test with common ports
PORT=80 node your_program.js
PORT=443 node your_program.js

# Test with invalid port numbers
PORT=-1 node your_program.js
PORT=abc node your_program.js

# Test with a port already in use
# (Make sure another program is already using the specified port)
PORT=8080 node your_program.js

# Test with a custom port using environment variable
PORT=5678 node your_program.js

במקום לפנות זמן נסו להוסיף אנרגיה

27/12/2023

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

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

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

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

טיפ ביטויים רגולאריים - לא חוזרים על Capturing Group

26/12/2023

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

values: 10 20 30 40

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

import re
text = "values: 10 20 30 40"
m = re.search(r'values: (?:(\d+)\s*)+', text)

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

>>> m.groups()
('40',)

מאיפה הוא הגיע ל 40? דרך טובה לחשוב על Capture Groups היא כמו בעבודה עם מילון, כך שאם יש יותר מערך אחד לקבוצה כל ערך דורס את זה שלפניו ונקבל רק את הערך האחרון. נכון שבדוגמה שלנו גם 10, 20 ו 30 היו התאמות של הקבוצה, אבל בגלל שזו קבוצה אחת רק 40 נשאר בערך שחזר מההתאמה.

במקום להכניס יותר מדי ערכים לקבוצה, במקרים כאלה יש להשתמש ב findall יחד עם ביטוי רגולארי לתוכן של קבוצה כדי לקבל את כל התוצאות:

>>> re.findall(r'\b(\d+)\b', text)
['10', '20', '30', '40']

למה עדיין אכפת לנו מההבדל בין == ל === ?

25/12/2023

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

https://www.reddit.com/r/reactjs/comments/18muk6w/whataretheinterviewquestionsinyou_have/

רשימה השאלות על JavaScript כוללת שאלות כמו "מה ההבדל בין let ל const?" ו"מה ההבדל בין == ל ===?".

רשימת השאלות על ריאקט כללה שאלות כמו "האם חייבים להשתמש ב JSX כשעובדים עם ריאקט?" ו"איך לשתף סטייט בין מספר קומפוננטות?".

שני דברים קופצים לעין משאלות אלה ושאר השאלות באותו עמוד - מצד אחד החיפוש אחרי אנשים שמבינים את העקרונות, ומצד שני האיטיות בה פיצ'רים חדשים נכנסים לרשימת השאלות. אפילו השאלות הלכאורה קשות שהופיעו שם היו על ההבדל בין useMemo ל useCallback או על React.cloneElement.

פיצ'רים כמו useTransition או useSyncExternalStore לא נכנסו לרשימה ואני חושב שזה לא מקרי, וגם Server Components עדיין לא נחשבים מיינסטרים. יש עדיין פער היום בין מה שמעניין את הצוות שבונה את ריאקט לבין מה שמעניין את הקהילה שבונה מוצרים עם ריאקט. כן אנחנו אוהבים להשתמש ב next.js אבל מעדיפים שיעבוד "כמו שאנחנו מכירים". אנחנו מוכנים לחיות עם Concurrent Mode כל עוד היקף השינוי הוא קטן, ובטח שלא נחפש בראיונות אנשים שיש להם ניסיון ספציפי בפיצ'רים האלה. האתגר הכי גדול של מפתחי ריאקט היום הוא עדיין איך להשתמש נכון ב useEffect.

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

(וכמו תמיד בפוסטים מהסוג הזה - תקנו אותי אם אני טועה. התראיינתם במקום שעובד בריאקט ודרשו מכם לממש משהו עם useSynxExternalStore? אל תתביישו וספרו לי על זה בתגובות)