• בלוג
  • למה צריך ואיך לנהל State גלובאלי של יישום ריאקט עם MobX

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

30/07/2020

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

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

1. ההבדל בין סטייט מקומי לסטייט גלובאלי

בניגוד לדפי HTML סטטיים, דפי ריאקט (או JavaScript באופן כללי) הם דינמיים, כלומר דברים יכולים להשתנות על המסך תוך כדי שאנחנו מסתכלים על האתר. יש אתרים שהם מאוד דינמיים ובהם המון דברים משתנים תוך כדי עבודה (לדוגמא Google Docs או Facebook) ואחרים בהם מעט מאוד תוכן משתנה על הדף: אולי איזה Popup קופץ, אולי יש לנו תיבה עם השלמה אוטומטית או תמונה שמתחלפת בגלריית תמונות.

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

  1. אם יש לנו גלריית תמונות - אז איזה תמונה מוצגת כרגע זה מידע שיישמר ב State (של מישהו)

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

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

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

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

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

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

2. ניהול סטייט גלובאלי באמצעות Singleton

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

  1. תהיה לנו קומפוננטת DataTable שתציג את המידע הגולמי בטבלא.

  2. תהיה לנו קומפוננטת DataStats שתציג סטטיסטיקות מעניינות על המידע.

  3. תהיה לנו קומפוננטת DataGraph שתציג את המידע בצורה גרפית.

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

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

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

בדוגמא שלנו נכתוב קובץ בשם data.js עם הקוד הבא:

class Data {
  constructor(headers) {
    this.lastGivenId = 0;
    this.data = [];
    this.size = 50;
  }

  setHeaders(headers) {
    this.headers = ['id', ...headers];
  }

  // receives an array and adds it to the data frame
  // returns the ID of the newly added row
  addRow(row) {
    if (this.data.length >= this.size) {
      this.data.shift();
    }
    const id = this.lastGivenId++;
    this.data.push([id, ...row]);
    return id;
  }

  // deletes a row by its id
  deleteRow(id) {
    this.data = this.data.filter(row => row.id !== id);
  }
}

export default new Data();

ועכשיו אפשר להוסיף Services חיצוניים לגמרי שיעדכנו את המידע - לדוגמא נכתוב את הקוד הבא בקובץ random_data_producer.js:

import Data from './data';

setInterval(function() {
  Data.addRow([Math.random(), Math.random()]);
}, 2000);

ואפשר לייצר תהליך נוסף שכל שתי שניות ימשוך את קואורדינטות תחנת החלל הבין לאומית ויוסיף אותן. כתבו את הקוד הבא בקובץ iss_data_producer.js:

import Data from 'data';

setInterval(async function() {
  const response = fetch('http://api.open-notify.org/iss-now.json');
  const coords = response.json();
  Data.addRow([coords.iss_position.latitude, coords.iss_position.longitude]);
}, 2000);

3. חיבור הסינגלטון לקומפוננטה באמצעות MobX

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

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

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

בחיבור כזה יש שני אתגרים:

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

  2. האתגר השני הוא שגם אם היה לנו כזה מנגנון של אירועים זה לא ממש היה עוזר. ריאקט יודעת לעדכן קומפוננטות רק כש State או Prop של הקומפוננטה מתעדכנים.

ברמת התיאוריה - ההתמודדות עם שני האתגרים תעבוד בערך כך:

  1. נרצה לעדכן את המחלקה שלנו כך שתדווח (לכל מי שירצה לשמוע) כשהמידע הפנימי התעדכן.

  2. נרצה לבנות קומפוננטת ריאקט ששומרת עותק של המידע ממחלקת Data אצלה ב State. כל פעם שאוביקט Data מדווח שהוא השתנה, הקומפוננטה תפעיל מחדש setState, תשמור את המידע החדש ב State שלה וכך ריאקט ידע להפעיל מחדש render ולבנות את הקומפוננטה מחדש.

ספריה לניהול סטייט גלובאלי בריאקט (MobX וגם Redux) היא ספריה שעוזרת לנו להתמודד עם שני אתגרים אלה. היא מאפשרת בלחיצת כפתור "לחבר" מנגנון דיווח אירועים למחלקה Data, ובלחיצת כפתור נוספת לחבר את המידע הפנימי מ Data ל State של קומפוננטת ריאקט.

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

הנה קוד המחלקה אחרי שילוב MobX:

import { observable, decorate } from 'mobx';

class Data {
  constructor(headers) {
    this.lastGivenId = 0;
    this.data = [];
    this.size = 50;
  }

  setHeaders(headers) {
    this.headers = ['id', ...headers];
  }

  // receives an array and adds it to the data frame
  // returns the ID of the newly added row
  addRow(row) {
    if (this.data.length >= this.size) {
      this.data.shift();
    }
    const id = this.lastGivenId++;
    this.data.push([id, ...row]);
    return id;
  }

  // deletes a row by its id
  deleteRow(id) {
    this.data = this.data.filter(row => row.id !== id);
  }
}

decorate(Data, {
  data: observable,
});

export default new Data();

בסך הכל הוספתי פקודת import אחת ואת הקריאה ל decorate בתחתית הקובץ. בהרבה דוגמאות בעבודה עם MobX משתמשים בכתיב ה Decorators (עם סימן @). אני בוחר כאן להישאר עם הכתיב הנקי של JavaScript כי הוא לא דורש שום Setup ב Webpack שלכם בשביל לעבוד וגם כי זה ברור יותר לראות את האפקט כשלומדים. בחיים האמיתיים תצטרכו לבחור את הכתיב שמרגיש לכם יותר פשוט.

בחזרה לריאקט - נניח שיש לנו קומפוננטה שצריכה להציג את המידע בצורה טבלאית. בשביל לחבר אותה למידע הגלובאלי אנחנו נשתמש בפונקציה בשם observer של ספריית mobx-react. בשביל לא להתאמץ בכתיבת טבלאות אני משתמש גם ב react-data-table והקוד המלא בקובץ table.js נראה כך:

import React from 'react';
import { observer } from 'mobx-react';
import DataTable from 'react-data-table-component';
import _ from 'lodash';
import Data from './mobx/data';

export default observer(function Table(props) {
  const headers = Data.headers;
  const data = Data.data.map(row => _.zipObject(headers, row));
  const columns = headers.map(key => ({
    name: key,
    selector: key,
    sortable: true,
  }));

  return (
    <DataTable title="Arnold Movies" data={data} columns={columns} />
  );
});

עיקר הקוד מתעסק בלהפוך את המערך הדו-מימדי מ Data למערך של אוביקטים ש DataTable יודע לעבוד איתו. אבל הדבר המעניין באמת כאן הוא המילה observer בכותרת הפונקציה. זו פונקציה שמקבלת קומפוננטה ומחזירה קומפוננטה אחרת - או בריאקטית זהו Higher Order Component. פונקציה זו מכילה את קסם ההקשבה - היא זו שדואגת לקחת את המידע מ Data, לשמור אותו ב State, וכל פעם שהמידע יתעדכן לשמור את העותק החדש בסטייט כדי שהקומפוננטה תצייר את עצמה מחדש ותציג את המידע העדכני.

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

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

4. הוספת עוד קומפוננטות שמחוברות לאותו מידע

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

import React from 'react';
import _ from 'lodash';
import { observer } from 'mobx-react';

import Data from './mobx/data';

export default observer(function Stats(props) {
  const numberOfRows = Data.data.length;
  const averageLatitude = Data.data.reduce((acc, val) => (acc + Number(val[1])) / numberOfRows, 0);
  const averageLongitude = Data.data.reduce((acc, val) => (acc + Number(val[2])) / numberOfRows, 0);

  return (
    <p>
      Number of rows: {numberOfRows}
      Average Lat: {averageLatitude}
      Average Long: {averageLongitude}
    </p>
  );
});

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

5. פרטים טכניים בשביל לגרום לקוד לעבוד

כל הקוד זמין אונליין בגיטהאב במאגר: https://github.com/ynonp/mobx-table-demo.

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

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import Data from './mobx/data';
import './mobx/iss_data_producer';
import './mobx/random_data_producer';

window.data = Data;
window.data.setHeaders(['lat', 'long']);

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

והשני הוא App.js שם נמצא הקוד שמאתחל את הקומפוננטות:

import React from 'react';
import logo from './logo.svg';
import './App.css';
import Table from './table';
import Stats from './stats';
function App() {
  return (
    <>
      <Stats />
      <Table />
    </>
  );
}

export default App;

בנוסף לפרויקט הבסיס של create-react-app הוספתי את הספריות הבאות למערכת:

$ yarn add mobx mobx-react react-data-table-component styled-components

6. לסיכום

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

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

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

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