Mach’ Deine React App wieder flott
Autor
Christian Südkamp
Software Developer
bei SYZYGY Techsolutions
Lesedauer
5 Minuten
Publiziert
03. Juli 2023
Wenn sich eine React-App träge anfühlt und es gar zum Flashing beim Laden oder bei Interaktion durch Nutzer:innen kommt, kann es sich lohnen, die App-Architektur näher zu betrachten.
Ein Faktor, der die Leistung von interaktiven Apps spürbar mindern kann, sind unnötige Re-Renders.
Nachfolgend erkläre ich was Re-Renders sind und wie man unnötige Re-Renders vermeidet. Abschließend zeige ich euch einen Vorher-/Nachher-Vergleich anhand einer Beispiel-App.
Was sind Re-Renders?
Ein Re-Render ist jeder Rendervorgang, der nach dem initialen Rendering erfolgt. Meistens geschieht das durch eine Interaktion des Users oder dem Laden von neuen Daten aus einer externen Quelle.
Dabei wird zwischen der nötigen und der unnötigen Variante unterschieden.
- Nötiger Re-Render: Der Re-Render einer Komponente, die selbst der Ursprung von neuen Informationen ist oder diese direkt verwendet, beispielsweise ein Select oder eine Paginierung.
- Unnötiger Re-Render: Ein Re-Render, der durch die gesamte App propagiert wird (“Down the component tree”), beispielsweise durch falsche Verschachtelung der Komponenten.
Bei kleinen, simplen React Apps mit “leichtgewichtigen” Komponenten fallen Re-Renders nicht ins Gewicht und machen sich auch visuell nicht bemerkbar. Bei größeren, komplexeren Applikationen mit “schwergewichtigen” Komponenten kann es jedoch zu Flashing und/oder Einschränkungen der Benutzbarkeit kommen.
Die nachfolgenden Überlegungen und Anti- / Patterns sind also nur auf interaktive, dynamische React Apps sinnvoll anzuwenden und nicht auf rein statische Applikationen.
Wodurch werden Re-Renders ausgelöst?
Re-Renders einer Komponente werden in der Regel durch die folgenden Events ausgelöst:
- Der interne State der Komponente verändert sich
- Die Eltern-Komponente re-rendert
- Der Wert des Context-Providers ändert sich
- Ein verwendeter Hook ändert sich
Hierzu jeweils ein (Pseudo-) Code-Beispiel:
Der interne State der Komponente verändert sich, z.B. durch useState oder useEffect
function Child() {
const [state, setState] = useState('');
useEffect(() => {
// 1. As the state changes, the component is re-rendered.
setState('mounted');
});
return <div>{state}</div>;
}
In diesem (Pseudo-)Code-Beispiel wird die State-Variable state im useEffect-Hook geändert, was einen Re-Render der Komponente zur Folge hat.
Die Eltern-Komponente re-rendert
function Parent() {
const [state, setState] = useState('');
useEffect(() => {
// 1. As the state changes, the component is re-rendered.
setState('mounted');
});
// 2. The child component is re-rendered.
return <Child />;
}
Wie im vorherigen Beispiel wird der Re-Render der Komponente durch die Änderung von state ausgelöst. Daraufhin werden auch sämtliche Kinder-Komponenten re-rendert, in diesem Fall <Child />.
Der Wert eines Context-Providers, den die Komponente nutzt, ändert sich
const ThemeContext = createContext('Monokai Pro');
function Component() {
// 1. If the value of ThemeContext changes, the component is re-rendered.
const theme = useContext(ThemeContext);
return <div>{theme}</div>;
}
Hier wird der Wert theme des Context ThemeContext verwendet. Ändert sich dieser Wert, wird die Komponente re-rendert.
Wenn sich der Context-Wert ändert, werden auch Komponenten re-rendert, die keine Kinder-Komponenten sind, aber diesen Context verwenden.
Ein verwendeter Hook ändert sich
// 1. The context value changes
const ThemeContext = createContext('Monokai Pro');
// 2. The state of this hook changes
const useTheme = () => {
useContext(ThemeContext);
return null;
};
function Component2() {
// 3. The component that uses the hook re-renders
const theme = useTheme();
return <div>{theme}</div>;
}
Antipatterns und Patterns
Ich möchte euch hierauf ein paar Patterns und Antipatterns zeigen, die ihr beim Aufbau eurer Komponenten verwenden bzw. vermeiden könnt.
Antipattern: Kinder-Komponenten in der Render Function erstellen
Eine Komponente innerhalb der Render Function einer anderen Komponente zu erstellen hat unter anderem zur Folge, dass diese Komponente bei jedem Rendern der Host-Komponente neu gemounted wird.
Das wiederum kann zur Performance-Einbrüchen führen, da der Vorgang des Re-Mountens in der Regel langsamer ist als der des Re-Renderns.
Weitere, möglicherweise ungewollte Nebeneffekte sind:
- Der State der Komponente wird bei jedem Re-Render der Hostkomponente zurückgesetzt
- useEffect Hooks ohne Dependencies werden bei jedem Re-Render ausgelöst
- Es kann zum Flashing der Komponente beim Re-Render kommen
- War die Komponente im Fokus, geht dieser beim Re-Render verloren
Lösung:
Die Kinder-Komponente außerhalb der Render-Funktion der Eltern-Komponente erstellen. Dadurch wird diese lediglich re-rendert anstatt re-mounted!
Pattern: Children als Props übergeben
Beinhaltet eine Komponente, die den State managed, eine oder mehrere große/schwere Kind-Komponenten, die nicht unnötig Re-Rendern sollen, kann man dieses Pattern anwenden.
Man kapselt das State-Management in einer kleineren Komponente, der man schließlich die großen Kind-Komponenten als children Prop übergibt. Auf diese Weise werden diese nicht mehr von State Updates ihrer Eltern-Komponente beeinflusst.
import { useState } from 'react';
const HeavyComponent = () => {
return <div>I'm a big boy!</div>;
};
const HeavyComponent2 = () => {
return <div>I'm a big boy, too!</div>;
};
const WrapperComponent = ({ children }) => {
const [state, setState] = useState(null);
return (
<div
onMouseEnter={(event) => {
setState(event.target);
}}
>
{children}
</div>
);
};
export default function ChildrenAsProps() {
return (
<WrapperComponent>
<HeavyComponent />
<HeavyComponent2 />
</WrapperComponent>
);
}
Pattern: Unnötige Re-Renders mit React.memo verhindern
Dieses Pattern kann eingesetzt werden, wenn es bspw. nicht möglich ist, eine große Komponente aus ihrer Eltern-Komponente zu extrahieren, wie es beim vorherigen Pattern getan wurde.
Wenn man eine Kind-Komponente mit React.memo wrapped, wird sie nicht re-rendered, es sei denn, ihre Props sind neu.
Hat die memoisierte Komponente eine Prop, die exemplarisch ein Objekt als Wert hat (und keinen primitiven Wert vom Typ String oder Number), wird dieses Objekt beim Re-Rendern der Eltern-Komponente neu erstellt, wodurch auch die memoisierte Komponente re-rendert.
Bei der Verwendung von React.memo macht React einen sog. Referential Equality Check der komplexen Props der memoisierten Komponente. Einfach gesagt, können zwei Objekte den gleichen Wert haben aber auf verschiedene Speicherbereiche verweisen.
Alle Props, deren Werte keine primitiven Typen haben, müssen memoisiert werden, um unnötige Re-Renders zu verhindern.
Im folgenden Beispiel werden sowohl die HeavyComponent als auch die dazugehörige Prop boy memoisiert, da das boy Object sich bei jedem Rendern der Eltern-Komponente ändert (oder genauer, die Object-Referenz von boy).
Solange sich nun der Wert von boy nicht ändert, wird HeavyComponent nicht re-rendert.
import React from 'react';
import { useMemo, useState } from 'react';
interface HeavyProps {
boy: {
name: string;
age: number;
};
}
const HeavyComponentMemo = React.memo(({ boy }: HeavyProps) => {
const { name, age } = boy;
return (
<div>
I'm {name} and I'm {age} years old!
</div>
);
});
export default function ReactMemo() {
const [state, setState] = useState(0);
const boy = useMemo(() => ({ name: 'John Cena', age: 36 }), []);
return (
<div
onClick={() => {
setState(state + 1);
}}
>
<HeavyComponentMemo boy={boy} />
</div>
);
}
Ein Voher-Nachher-Vergleich
Um die unnötigen Re-Renders zu verdeutlichen, habe ich mit dem React Profiler und der Einstellung Highlight updates when components render. ein paar Interaktionen mit meiner Beispiel-App aufgenommen.
In der Vorher-Variante habe ich die State Variable fav in der Eltern-Komponente verwaltet, in der Nachher-Variante habe ich diesen State an die Kind-Komponente FavoriteDish weitergegeben.
Vorher
Die Komponente Menu wird bei jedem Update von fav re-rendert.
import styles from '../styles/Home.module.css';
import { createContext, useContext, useEffect, useState } from 'react';
import { Menu } from '@/components/Menu';
export default function Home() {
const [fav, setFav] = useState('');
function handleFav(event: React.ChangeEvent<HTMLInputElement>) {
const fav = event.target.value;
setFav(fav);
}
return (
<div className={styles.container}>
<h1>Best Of BKSF</h1>
<p>
Die nachfolgende Liste enthält meine persönlichen Lieblingsgerichte. Du
kannst sie nach mehreren Kriterien (z.B. der Schärfegrad) filtern, um
Dich für Deinen nächsten Besuch inspirieren zu lassen.
</p>
<p>
Was ist Dein Lieblingsricht? Gib' die Nummer im nachfolgenden Feld ein.
</p>
<input
type='text'
name='fav'
id='fav'
onChange={handleFav}
maxLength={3}
/>
<p>Deine Lieblingsgericht hat die Nummer: {fav}</p>
<Menu />
</div>
);
}
Hier die dazugehörige Aufnahme. Re-renderte Komponenten sind im Video an der grünen Border ersichtlich.
Nachher
Die Komponente Menu wird nicht mehr durch das Updaten von fav bei Nutzereingabe re-rendert, da der State nicht mehr in der Eltern-Komponente gemanaged wird.
import styles from '../styles/Home.module.css';
import { FavoriteDish } from '@/components/FavoriteDish';
import { Menu } from '@/components/Menu';
export default function Home() {
return (
<div className={styles.container}>
<h1>Best Of BKSF</h1>
<p>
Die nachfolgende Liste enthält meine persönlichen Lieblingsgerichte. Du
kannst sie nach mehreren Kriterien (z.B. der Schärfegrad) filtern, um
Dich für Deinen nächsten Besuch inspirieren zu lassen.
</p>
<FavoriteDish />
<Menu />
</div>
);
}
In den Commits des React Profilers taucht Menu nur noch im ersten Commit auf, die grüne Border ist verschwunden. Hier das dazugehörige Video.
Obwohl sich die Renderzeiten beim Vorher-Nachher-Vergleich merklich kaum unterscheiden, ergibt sich ein starker visueller Kontrast.
Fazit
Ausgehend von den Erkenntnissen zum Thema Re-Renders zeigt sich: Viele unnötige Re-Render lassen sich durch eine andere Komposition der Komponenten oder das Auslagern des State Management vermeiden. Bei Komponenten mit aufwendigen Berechnungen kann auch der Einsatz von useMemo oder useCallback sinnvoll sein, wie etwa bei großen Listen.
Um die Re-Renders sichtbar zu machen, kann man sie mithilfe des React Profilers aufnehmen. Um kein verfälschtes Ergebnis aufgrund von Performance-Unterschieden zwischen den Umgebungen zu erhalten (z.B. ist der React-Code auf Prod im Regelfall optimiert), empfehle ich, diese Profile frühzeitig im Entwicklungsprozess zu beachten.
Head of Technology