React: variabilă globală, penalizare severă de performanţă (solved)

Scenariul e aşa: aplicaţia este magazin online, care are un cart şi o listă de produse, care conţine butoane de tip “Add to cart”. Când se apasă pe “Add to card”, trebuie să se actualizeze un badge de lângă iconiţa care reprezintă cart-ul.

Implementarea (aparent) naivă a fost să creez un state global, folosind createContext()/useContext(). Acel state l-am pus în componenta App.

Problema: în mod evident, ori câte ori state-ul global este actualizat, se re-randează absolut tot App-ul şi descendenţii lui. Chiar dacă re-randarea este doar în VDOM, pe un telefon mai slab lag-ul este foarte vizibil şi enervant.

Idei?

Proof of concept mai jos:

import { useState, createContext, useContext } from "react";

const CartContext = createContext({});

function Cart() {
    const { numProds, setNumProds } = useContext(CartContext);

    return (
        <div>Num. products: { numProds }</div>
    );
}

function ProductList() {
    const { numProds, setNumProds } = useContext(CartContext);

    const items = [1, 2, 3, 4, 5].map((product) =>
        <li key={product}>
            Produs {product}
            <button onClick={ () => setNumProds(numProds + 1) }>Add to cart</button>
        </li>
    );

    return (
        <ul>{items}</ul>
    )
}

function App() {
    const [ numProds, setNumProds ] = useState(0);

    return (
        <CartContext.Provider value={{ numProds, setNumProds }}>
            <div>
                <Cart />
                <ProductList />
            </div>
        </CartContext.Provider>
    );
}


export default App;

Cred ca ar fi mai protivit Redux Toolkit pentru un state global. Cat despre provider si context o sa ti se randeze la update tot ce o sa fie intre CartContext.Provider si toate componentele lor dar acum depinde cum ai si componentele facute, este putin tricky sa folosesti context si provider. Din
cate stiu nu ar trebui sa fie folosit pentru global state management, pentru global state folosesti Redux sau alte librarii dar pentru a pasa state/values sau a face mici update-uri intre componente care nu au legatura cu global state atunci poti sa folosesti context si provider.

Soluția e simplă, nu schimba state-ul global. Fiecare prop și state care e folosit provoacă rerender dacă se schimbă referința.

Dacă ai folosit direct useContext intr-un parinte si schimbi ceva mai jos vei reranda tot. Grijă mare să dai obiecte fără referințe cu map/filter că îți va da mereu o referința nouă. Nici valorile nu sunt ok, trebuie să fie un obiect memoizat.

Folosește proxy state ca valtio sau zustand (Un redux toolkit mai simplu)
E valabil și așa să nu schimbi ceva de pe state-ul global, trebuie să selectezi ce componente legi de state și să faci memoizare (useMemo/useCallback la un state specific) la ce nu se schimbă.

2 Likes

Interesant trick-ul cu useMemo(), chiar dacă nu mi-a rezolvat problema măcar am învăţat o chestie în plus. Se face în continuare randarea în VDOM, dar reduce din cantitatea de js executat (din câte înţeleg, e un soi de caching cate poate fi invalidat în funcţie de anumite dependenţe).

function App() {
    const [ numProds, setNumProds ] = useState(0);

    return (
        <CartContext.Provider value={{ numProds, setNumProds }}>
            <div>
                <Cart />
                { useMemo(() => <ProductList />, []) }
            </div>
        </CartContext.Provider>
    );
}

Il folosesti gresit.

De ce e greşit? Din câte înţeleg, ProductList se execută o singură dată, rezultatul se stochează într-un soi de cache, după care ori de câte ori App este re-rerandat, ProductList nu se mai execută deloc, ci se foloseşte direct rezultatul anterior stocat în cache. Nu asta e ideea? În cazul de faţă “ProductList” este acel “expensive computation” despre care se vorbeşte în documentaţie.

1 Like

Nu mi-am dat seama ca faci memo la <ProductList />.

Ah, probabil pentru că am făcut-o inline :slight_smile: De lene, într-un proiect real probabil nu aş face aşa.

O problema ce o au unii cu liste e ca randeaza toata lista in loc sa foloseasca paginare sau scroll virtual. Deci vezi daca poti optimiza prima data partea asta.

Daca a reduce tree-ul nu e o optinue cu memo sau useMemo poti rezolva:

1 Like

useMemo() nu prea ajută. Chiar dacă nu se execută funcția propriu-zisă, dacă tree-ul e mare “reconcilierea” (ce terminologie tâmpită folosește React) tot omoară procesorul.

Frumos ar fi să poți să conectezi două componente între ele (de ex. producer și consumer) și alea n-au decât să se randeze între ele până le vine rău, fără să influiențeze aiurea alte chestii.

Până la urmă am rezolvat problema, plecând de la sugestia lui @isti37, utilizând zustand și îi mulțumesc pe această cale. L-am wrapat într-un custom hook și aparent face ce trebuie, randează strict componenta care mă interesează, nimic altceva.

Nu sunt 100% mulțumit deoarece nu înțeleg prea bine cum funcționează, dar pentru moment I take it, ca să pot să trec mai departe.

Las pentru posteritate un exemplu full funcțional, pentru cei care poate vor trece prin aceleași cazne.

// useCart.js

import create from "zustand";

export const useCart = create(set => ({
    numItems: 0,
    up: () => set(state => ({ numItems: state.numItems + 1 }))
  }))
// Cart.js

import { useCart } from "./useCart";

export default function Cart() {
    const numItems = useCart(state => state.numItems)

    return (
        <div>{ numItems }</div>
    )
}
// Button.js

import { useCart } from "./useCart";

export default function Button() {
    const up = useCart(state => state.up)

    return (
        <button onClick={ up }>Click me!</button>
    )
}
// App.js

import Button from "./Button";
import Cart from "./Cart";

function App() {
    return (
        <div>
            <Cart />
            <Button />
        </div>
    );
}

export default App;