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

עידכון אפליקציית ריאקט אחרי קבלת אירוע מהשרת

23/02/2023

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

1. למה זה קשה

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

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

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

2. איך עושים את זה בריאקט

באפליקציית React ו Redux, מימוש מנגנון דומה הוא מאוד פשוט. זה מה שנעשה בגדול:

  1. ניצור Web Socket שיקשיב להודעות מהשרת.

  2. כל פעם שנקבל הודעה נקרא ל dispatch, אבל במקום לשלוח את ה Action שהיה משנה את המסך, נשלח Action אחר שעוטף אותו ושומר אותו בתור הודעות.

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

3. קוד לדוגמה

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

// file: src/redux/slices/text
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'

export interface TextState {
  value: string
}

const initialState: TextState = {
  value: "Hello World",
}

export const slice = createSlice({
  name: 'text',
  initialState,
  reducers: {
    change(state, action: PayloadAction<string>) {
      state.value = action.payload;
    }
  },
})

// Action creators are generated for each case reducer function
export const actions = slice.actions
export default slice.reducer

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

// file: src/redux/slices/save_for_later.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { Action } from '@reduxjs/toolkit'
import { clearPending } from './run_pending_thunk';

export interface SaveForLaterState {
  pending: Array<Action>;
}

const initialState: SaveForLaterState = {
  pending: [],
}

export const slice = createSlice({
  name: 'saveForLater',
  initialState,
  reducers: {
    enqueue(state, action: PayloadAction<Action>) {
      state.pending.push(action.payload);
    }
  },
  extraReducers: (build) => {
    build.addCase(clearPending, () => {
      return initialState;
    })
  }
})

// Action creators are generated for each case reducer function
export const actions = slice.actions
export default slice.reducer

בנוסף אני יוצר קובץ עם Thunk שיהיה אחראי על פתיחת כל ה Actions מתוך התור כשהמשתמש לוחץ על כפתור הסינכרון:

// file: src/redux/slices/run_pending_thunk.ts

import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../store";

export const clearPending = createAction('saveForLater/clearPending');

export const runPending = createAsyncThunk<void, void, {state: RootState }>('saveForLater', async (arg: void, { getState, dispatch }) => {
  const pending = getState().saveForLater.pending;
  pending.forEach(dispatch);
  dispatch(clearPending());
});

הקובץ store.ts נראה כך:

// file: src/redux/store.ts

import { configureStore } from '@reduxjs/toolkit'
import textReducer from './slices/text';
import saveForLaterReducer from './slices/save_for_later';
import { actions } from './slices/save_for_later';
import { actions as textActions } from './slices/text';

export const store = configureStore({
  reducer: {
    text: textReducer,
    saveForLater: saveForLaterReducer,
  },
})

setTimeout(() => {
  store.dispatch(actions.enqueue(textActions.change("New text from server")));
}, 5000);

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch

בשביל הדוגמה במקום ליצור Web Socket בסך הכל הפעלתי Timer, אבל בתוכנית אמיתית ה setTimeout יוחלף ב Web Socket.

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

import './App.css'
import { useSelector, useDispatch } from 'react-redux'
import { RootState, AppDispatch } from './redux/store';
import { actions } from './redux/slices/text';
import { runPending } from './redux/slices/run_pending_thunk';
const useApplicationDispatch: () => AppDispatch = useDispatch;

function App() {
  const dispatch = useApplicationDispatch();
  const text = useSelector((state: RootState) => state.text.value);
  const hasPendingActions = useSelector((state: RootState) => state.saveForLater.pending.length > 0);

  const sync = () => {
    dispatch(runPending());
  }

  return (
    <div className="App">
      {hasPendingActions && (
      <p>
        New text from server available.
        <button onClick={sync}>Click To Sync</button>
      </p>)}
      <input type="text" value={text} onChange={(e) => dispatch(actions.change(e.target.value))} />
    </div>
  )
}

export default App

כשיש Actions בתור יופיע משפט שאומר שיש מידע חדש מהשרת וכפתור סינכרון. לחיצה על הכפתור תפעיל את ה thunk שיעשה dispatch לכל ה actions בתור וימחק אותם.

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

enqueue(state, action: PayloadAction<Action>) {
  state.pending.push(action.payload);
}

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