מובאקס - מעבר לבייסיקס

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

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

  1. מובאקס בעשר דקות

  2. איך לנהל State גלובאלי של יישום ריאקט עם MobX

  3. וובינר מבוא למובאקס

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

1. ריאקטיביות

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

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

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

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

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

class Clock {
  constructor() {
    this.ticks = 0;
    setInterval(() => {
      this.tick();
    }, 1000);
  }

  tick() {
    this.ticks++;
  }
}

אם אני רוצה לקחת את השעון שיצרתי ולהדפיס את הערך של ticks כל פעם שהוא משתנה אני חייב לשנות את קוד המחלקה, כי כרגע השעון לא מספק לי דרך טובה לתקשר איתו. אפשרות אחת לשינוי תהיה להכניס את פקודת ההדפסה לתוך הפונקציה tick; אפשרות קצת יותר טובה היא להוסיף מנגנון אירועים לשעון כך שפונקציית הבנאי תקבל Callback Function ותפעיל אותה כל פעם ש ticks נקראת.

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

import {
  makeObservable,
  observable,
  action,
} from "mobx";

class ReactiveClock {
  constructor() {
    this.ticks = 0;
    setInterval(() => {
      this.tick();
    }, 1000);

    makeObservable(this, {
      ticks: observable,
      tick: action
    });
  }

  tick() {
    this.ticks++;
  }
}

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

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

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

const c = new ReactiveClock();

autorun(() => {
    console.log(`Tick: ${c.ticks}`);
});

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

Tick: 0

והמספר ירוץ לפי תקתוקי השעון.

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

const c = new ReactiveClock();
let isFirst = true;

autorun(() => {
  if (isFirst) {
    console.log(`Setting isFirst to false`);
    isFirst = false;
  } else {
    console.log(`Not a first time - print the value`);
    console.log(`Tick: ${c.ticks}`);
  }
});

הפעם ההדפסה היחידה שאני מקבל היא:

Setting isFirst to false

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

const c = new ReactiveClock();
let isFirst = true;

reaction(
  () => [c.ticks],
  (value, previousValue, reaction) => {
  if (isFirst) {
    console.log(`Setting isFirst to false`);
    isFirst = false;
  } else {
    console.log(`Not a first time - print the value`);
    console.log(`Tick: ${c.ticks}`);
  }
});

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

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

// NOT WORKING CODE

async function main() {
  const c = new ReactiveClock();
  const minutes = c.ticks / 60;
  await new Promise((r) => setTimeout(r, 60000));
  console.log(minutes);
}

main();

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

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

async function main() {
  const c = new ReactiveClock();
  const minutes = computed(() => c.ticks / 60);
  await new Promise((r) => setTimeout(r, 60000));
  console.log(minutes.get());
}

main();

התיקון מורכב משני שינויים:

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

  2. ערך ההחזר של computed הוא אוביקט ובשביל לקבל את ערכו אני מפעיל את המתודה get שלו.

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

class ReactiveClock {
  constructor() {
    this.ticks = 0;
    setInterval(() => {
      this.tick();
    }, 1000);
    makeObservable(this, {
      ticks: observable,
      minutes: computed,
      tick: action
    });
  }

  tick() {
    this.ticks++;
  }

  get minutes() {
    console.log(`Calculate minutes`);
    return this.ticks / 60;
  }
}

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

async function main() {
  const c = new ReactiveClock();
  console.log(c.minutes);
  console.log(c.minutes);
  console.log(c.minutes);
}

main();

ידפיס שלוש פעמים את ההודעה:

Calculate minutes 

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

async function main() {
  const c = new ReactiveClock();
  autorun(() => {
    console.log(c.minutes);
  });

  console.log(c.minutes);
  console.log(c.minutes);
  console.log(c.minutes);
}

main();

מעניין לשים לב שההדפסה בתוך ה getter תופעל כל שניה, אבל ההדפסה בתוך ה autorun של מספר הדקות שעברו תופעל רק פעם בדקה.

2. מידע ריאקטיבי וקומפוננטות ריאקט

הבחירה לשמור מידע גלובאלי של היישום בתוך מבנה ריאקטיבי של מובאקס מאפשרת לנו לחבר בקלות את המידע לקומפוננטות ריאקט ב UI במקום סתם להדפיס אותו ב console.log. יש אפילו מודול מובנה במובאקס בשם mobx-react-lite שדואג לכל החיבורים בשבילנו. בואו נראה את זה בפעולה עם שתי דוגמאות - השעון שכתבנו ודוגמת מונה לחיצות ריאקטיבי.

נתחיל עם השעון - אני לוקח את אותו קוד שכבר כתבתי ומוסיף לו שורת default export לשעון חדש שאני יוצר, ושומר את הכל בקובץ בשם ReactiveClock.js. זה הקוד:

import { makeObservable, observable, computed, action } from "mobx";

class ReactiveClock {
  constructor() {
    this.ticks = 0;
    setInterval(() => {
      this.tick();
    }, 1000);
    makeObservable(this, {
      ticks: observable,
      minutes: computed,
      tick: action
    });
  }

  tick() {
    this.ticks++;
  }

  get minutes() {
    console.log(`Calculate minutes`);
    return Math.floor(this.ticks / 60);
  }
}

const clock = new ReactiveClock();
export default clock;

עכשיו אני פותח קובץ ריאקט חדש וכותב בו את הקוד הבא:

import "./styles.css";
import { observer } from "mobx-react-lite";
import clock from "./mobx/ReactiveClock";

export default observer(function App() {
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <p>Ticks: {clock.ticks}</p>
      <p>Minutes: {clock.minutes}</p>
    </div>
  );
});

וזה הכל.

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

או בקישור: https://codesandbox.io/s/currying-sun-h8wguw?file=/src/App.js

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

import { makeObservable, observable, computed, action } from "mobx";

class Counter {
  constructor() {
    this.value = 0;
    makeObservable(this, {
      value: observable,
      click: action.bound,
    });
  }

  click() {
    this.value++;
  }
}

const counter = new Counter();
export default counter;

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

click: action.bound,

ביום רגיל הייתי משתמש ב bind מה constructor, אבל בגלל שאני קורא ל makeObservable גישה זו לא תעבוד. הפיתרון הוא לעדכן את מובאקס שאני רוצה לאפשר ללקוחות מחוץ למחלקה להשתמש בפונקציה ולקבל תמיד את הערך הנכון של this, וזאת המשמעות של action.bound.

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

import "./styles.css";
import { observer } from "mobx-react-lite";
import counter from "./mobx/counter";

const Counter = observer(function Counter() {
  return (
    <div>
      <p>Value = {counter.value}</p>
      <button onClick={counter.click}>+1</button>
    </div>
  );
});

export default function App() {
  return (
    <div className="App">
      <Counter />
      <Counter />
      <Counter />
    </div>
  );
}

וגם את הדמו הזה העליתי לקודסנדבוקס ויכולים לראות אותו בקישור: https://codesandbox.io/s/gifted-ride-qnoj92?file=/src/App.js

או כאן למטה:

3. הבעיה עם תבנית Render Props ואיך להתאים אותה לעבודה ריאקטיבית

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

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

תבנית Render Props היא מבלבלת כי הקוד שמגיע מבחוץ, כברירת מחדל, לא מוגדר בתור Observer ולכן לפעמים שינויים לא ייקלטו. בואו נמחיש את הבאג עם תוכנית קטנה לניהול רשימת מספרים אקראיים. תחילה אני כותב מחלקה במובאקס שיודעת לייצר ולשמור 10 מספרים אקראיים:

import _ from "lodash";
import { makeObservable, observable, action } from "mobx";

class Randomizer {
  constructor() {
    this.shuffle();

    makeObservable(this, {
      values: observable,
      shuffle: action.bound,
      incAll: action.bound
    });
  }

  shuffle() {
    this.values = _.range(10).map((x) => _.random(1, 100));
  }

  incAll() {
    for (let i = 0; i < this.values.length; i++) {
      this.values[i] += 1;
    }
    console.log(this.values);
  }
}

const randomizer = new Randomizer();
export default randomizer;

עכשיו אני ממשיך לחבר אותה לקומפוננטות ריאקט עם הקוד הבא ב App.js:

import "./styles.css";
import { observer, Observer } from "mobx-react-lite";
import randomizer from "./mobx/randomizer";

function defaultRenderItem(value) {
  return <span>{value}</span>;
}

function RandomList(props) {
  const { items, renderItem = defaultRenderItem } = props;
  return (
    <ul>
      {items.map((v, index) => (
        <li key={index}>{renderItem(v)}</li>
      ))}
    </ul>
  );
}

export default observer(function App() {
  return (
    <div className="App">
      <button onClick={randomizer.shuffle}>Shuffle</button>
      <button onClick={randomizer.incAll}>+1 </button>
      <RandomList
        items={randomizer.values}
        renderItem={(item) => <span>{item}</span>}
      />
    </div>
  );
});

אפשר לראות גם אותו לייב בקודסנדבוקס כאן:

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

נתחיל בבעיה והיא הפונקציה RandomList:

function RandomList(props) {
  const { items, renderItem = defaultRenderItem } = props;
  return (
    <ul>
      {items.map((v, index) => (
        <li key={index}>{renderItem(v)}</li>
      ))}
    </ul>
  );
}

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

הפעולה השניה, shuffle, דווקא כן עובדת בגלל שהיא משנה את המערך values עצמו. הקומפוננטה App מסתכלת על המערך values וכשהוא יוחלף בחדש גם היא תתרנדר מחדש. פעולת הפלוס היא הבעייתית כי היא משנה את האיברים בתוך המערך, ועליהם מי שמסתכל זה רק הקומפוננטה RandomList בפונקציית ה map שהיא מפעילה.

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

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

  1. אנחנו צריכים להפוך את הקוד של renderItem ל Observer. זה לא בעיה כי זה קוד שאנחנו מעבירים מבחוץ לתוך הקומפוננטה.

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

בצורה כזאת הגישה לאוביקט ריאקטיבי תחזור להיות מתוך קוד ריאקטיבי וכל השינויים יוצגו כמו שצריך על המסך. אפשר לראות את הפיתרון בקודסנדבוקס בקישור: https://codesandbox.io/s/gallant-parm-p1nwgm?file=/src/App.js

או מוטמע כאן:

4. מה עושים עם קוד אסינכרוני שמשנה מידע ריאקטיבי

אם כל המידע שלנו נשמר במובאקס, רק הגיוני לנסות להשתמש בו גם בתור מנהל לבקשות הרשת שלנו. לפני שנגיע לדבר על Action-ים אסינכרוניים, בואו ניקח את הגישה הנאיבית ונכתוב קוד אסינכרוני במובאקס. בדוגמה הבאה אני רוצה לכתוב מערכת שמציגה מידע מתוך swapi כלומר מידע על דמויות, סרטים, חלליות ומה לא ממלחמת הכוכבים. אני מתחיל עם קלאס מובאקסי בשם Repo שמספק ייצוג צד-לקוח לטבלה. ל Repo יש Endpoint, שזה הנתיב בשרת ממנו הוא לוקח את הנתונים, ופונקציה בשם fetch שמקבלת מזהה כלשהו ושומרת את המידע שמתאים לו. הריפו מוגבל לשמירת 10 פריטים וכל הפריטים נשמרים בצורה ריאקטיבית. זה הקוד:

import _ from "lodash";
import { makeObservable, observable, action, runInAction } from "mobx";

const MAX_CACHE_SIZE = 10;

class Repo {
  constructor(endpoint) {
    this.endpoint = endpoint;
    this.index = 0;
    this.items = {};

    makeObservable(this, {
      items: observable,
      fetch: action
    });
  }

  fetch(id) {
    if (this.items[id]) {
      return;
    }

    fetch(`${this.endpoint}/${id}`)
      .then((res) => res.json())
      .then((data) => {
        this.items[id] = {
          id,
          data,
          index: ++this.index
        };

        if (Object.keys(this.items).length > MAX_CACHE_SIZE) {
          const oldestItem = _.minBy(Object.values(this.items), (i) => i.index);
          delete this.items[oldestItem.id];
        }
      });
  }
}

export const charactersRepo = new Repo("https://swapi.dev/api/people");

הקובץ מייצא אוביקט Repo שמחובר ל REST Endpoint עבור דמויות ממלחמת הכוכבים.

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

const SWCharacter = observer(function SWCharacter() {
  const [id, setId] = useState("");
  const data = charactersRepo.items[id]?.data;

  useEffect(() => {
    if (id !== "") {
      charactersRepo.fetch(id);
    }
  }, [id]);

  console.log(`render items id = ${id}`);

  return (
    <div>
      <input type="number" value={id} onChange={(e) => setId(e.target.value)} />
      {data && (
        <div>
          <p>Name: {data.name}</p>
          <p>Birth Year: {data.birth_year}</p>
        </div>
      )}
    </div>
  );
});

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

const data = charactersRepo.items[id]?.data;

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

החלק השני הוא האפקט:

useEffect(() => {
  if (id !== "") {
    charactersRepo.fetch(id);
  }
}, [id]);

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

יש רק בעיה אחת עם הקוד אותה אנחנו רואים בקונסול בהודעת האזהרה:

[MobX] Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed. Tried to modify: Repo@5.items.2? 

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

אבל רגע, הפונקציה שלי fetch דווקא כן הוגדרה כ action נכון? הנה הקריאה ל makeObservable:

makeObservable(this, {
  items: observable,
  fetch: action
});

כן טוב - הפונקציה fetch היא action, אבל הקוד שבתוך ה then של ההבטחות הוא כבר לא. כלומר בקריאה הזו:

fetch(`${this.endpoint}/${id}`)
  .then((res) => res.json())
  .then((data) => {
    this.items[id] = {
      id,
      data,
      index: ++this.index
    };

    if (Object.keys(this.items).length > MAX_CACHE_SIZE) {
      const oldestItem = _.minBy(Object.values(this.items), (i) => i.index);
      delete this.items[oldestItem.id];
    }
  });

שתי פונקציות ה Callback שמועברות לשני הבלוקים של then כבר אינן נחשבות בתור action. כשפונקציית ה Callback השניה משנה את this.items זה גורם לאזהרה שראינו.

הפיתרון הוא לעטוף את החלק שמשנה משתנים ריאקטיביים בתוך קריאה ל runInAction, פונקציה של mobx שמקבלת פונקציה והופכת אותה ל action. הקוד המתוקן נראה כך:

  fetch(id) {
    if (this.items[id]) {
      return;
    }

    fetch(`${this.endpoint}/${id}`)
      .then((res) => res.json())
      .then((data) => {
        runInAction(() => {
          this.items[id] = {
            id,
            data,
            index: ++this.index
          };

          if (Object.keys(this.items).length > MAX_CACHE_SIZE) {
            const oldestItem = _.minBy(
              Object.values(this.items),
              (i) => i.index
            );
            delete this.items[oldestItem.id];
          }
        });
      });
  }

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

מוזמנים לראות את הקוד ולשחק איתו בקודסנדבוקס בקישור: https://codesandbox.io/s/distracted-gagarin-qve7lc?file=/src/mobx/swapi.js

או בהטמעה כאן:

5. סיכום - טיפים לשימוש ב MobX

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

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

  2. שימו לב מתי ניגשים למשתנים ריאקטיביים. כל גישה למשתנה ריאקטיבי יוצרת יחס של האזנה לשינויים במשתנה זה.

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

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