Waarom rendert dit opnieuw?
Wat een state update doet met een React component tree, en waar je state neerzet zodat dat minder wordt.
Dit begon als slides voor een talk over React. Op een slide kun je alleen een plaatje van een render laten zien, dus hier zijn de plaatjes echt: elk blok in een diagram hieronder wordt getekend door een echt component op deze pagina.
Een blok licht op wanneer React dat component aanroept, en het getal erboven telt hoe vaak dat is gebeurd. Onderstreepte blokken kun je aanklikken.
01model
React is geen template engine.
Een template wordt één keer gevuld met data. Alles wat daarna gebeurt is losse JavaScript, die elementen opzoekt en aanpast.
<h1>{{ account.name }}</h1>
<button data-modal-button>Edit profile</button>
document.querySelector('[data-modal-button]')
.addEventListener('click', openModal)
React vraagt om een andere manier van denken. Een component is een functie. Die krijgt de huidige state en geeft terug hoe de UI er voor die state uit moet zien.
function AccountHeader({ account }) {
const statusLabel = account.isActive ? 'Active' : 'Inactive'
return (
<header>
<h1>{account.name}</h1>
<p>{statusLabel}</p>
<button>Edit profile</button>
</header>
)
}
Je schrijft nooit de code die de pagina aanpast. Je beschrijft de UI voor één state, en als de state verandert roept React de functie opnieuw aan en zoekt zelf het verschil uit: UI = fn(state).
De rest van deze post gaat over die ene stap. Welke functies roept React opnieuw aan, en waarom juist die?
02de regel
State is data die React voor je bewaart tussen renders. Je komt erbij via useState, useReducer, useSyncExternalStore of useContext. Omdat React je de state aanreikt, weet React ook wanneer je die verandert, en een verandering plant een render in.
Wat daarna gebeurt is het hele reactivity model:
- state verandert
- het component dat de state bezit rendert opnieuw
- de children renderen mee
Renders gaan de tree in naar beneden. Nooit omhoog en nooit opzij. Update App en alle zeven components renderen. Update Logo en het is er één.
React controleert niet of een child andere props kreeg. De parent rendert, dus het child rendert. Het enige verschil tussen die twee updates is waar de state leeft.
03commit
"Rerenderen" klinkt duur, omdat het klinkt alsof de browser aan het werk gaat. Meestal is dat niet zo. Een render is React die jouw functie aanroept. Daarna vergelijkt React het resultaat met het vorige, en alleen de verschillen worden naar de DOM geschreven. Die tweede stap is de commit.
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
<Value count={count} />
<Label />
</button>
)
}
function Value({ count }) {
return <b>{count}</b>
}
function Label() {
return <span>Clicks</span>
}
Label rendert bij elke klik en zijn DOM node wordt nooit aangeraakt. Een render die niets verandert kost een functieaanroep en een vergelijking. Daarom zijn de meeste rerenders prima.
Het wordt pas een probleem als de functie zelf traag is, of als het er heel veel zijn. Goed om te weten voordat je naar een oplossing grijpt.
04state
Welk component heeft deze state echt nodig?
Als renders naar beneden lopen vanaf de plek waar de state leeft, dan bepaalt die plek hoeveel er rendert. Neem een account screen met een button die een modal opent. De voor de hand liggende plek voor isOpen is het screen.
function AccountScreen() {
const [isOpen, setIsOpen] = useState(false)
return (
<main>
<Button onClick={() => setIsOpen(!isOpen)}>
Edit profile
</Button>
{isOpen && <EditModal />}
<Overview />
<Feed />
</main>
)
}
Met de state in AccountScreen renderen bij het openen van de modal ook Overview en Feed, die er niets mee te maken hebben. Verplaats de state naar een component dat alleen de button en de modal bevat, en dezelfde klik rendert alleen die.
State hoort dus zo laag mogelijk. Buttons, dropdowns, modals en inputs kunnen bijna altijd hun eigen state bezitten. Lift state alleen als meerdere components hem echt delen, en dan niet verder dan hun laagste gedeelde parent.
Er is hier niets geoptimaliseerd. De state is verhuisd, en de render boundary verhuisde mee.
05memo
memo geeft een component een uitweg. Als de parent rendert, vergelijkt React eerst de nieuwe props met de vorige. Zijn ze allemaal hetzelfde, dan wordt het component overgeslagen, met alles eronder.
Die vergelijking gaat op reference. Een object of functie die tijdens de render van de parent wordt aangemaakt is elke keer een nieuwe. Daarom komt memo zelden alleen: het object heeft useMemo nodig en de callback useCallback.
function Overview(props) {
// ...
}
function AccountScreen({ account }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(!isOpen)}>
Edit profile
</Button>
{isOpen && <EditModal />}
<Overview
account={account}
filters={{ status: 'active' }}
onSelect={(id) => openAccount(id)}
/>
<Feed />
</>
)
}
- AccountScreen geeft door
- account
- elke render hetzelfde
- filters
- elke render nieuw
- onSelect
- elke render nieuw
- Overview rendert mee met zijn parent.
Zet alleen memo aan en er verandert niets, want filters en onSelect zijn elke render nieuw. Het overslaan begint pas als ze allebei stable zijn. Haal er één weer weg en het werkt niet meer, zonder error of warning.
Dat maakt memoization fragiel. En sommige renders houdt het nooit tegen: de eigen state van het component, een context die het leest, en props die echt veranderd zijn.
De hooks hebben zelf ook een prijs. Ze bewaren een waarde en vergelijken bij elke render de dependencies. Hier kost dat meer dan het oplevert:
const fullName = useMemo(() => {
return firstName + ' ' + lastName
}, [firstName, lastName])
useCallback voorkomt op zichzelf geen enkele render. Het loont pas als iets de reference vergelijkt: een memo component, de dependencies van een useEffect, of een library die stable references verwacht.
Voordat ik iets memoize vraag ik dus eerst waarom het component überhaupt rendert, en daarna werk ik in deze volgorde:
- uitzoeken waar de render begint
- de state zo laag mogelijk zetten
- composition gebruiken
- memoizen wat dan nog duur is
De React Compiler schrijft de useMemo en useCallback voor je. Waar je state leeft bepaalt hij niet, dus de eerste drie stappen blijven van jou.
06context
Context is een subscription.
Context lost prop drilling op. Een provider maakt een value beschikbaar, en elk component eronder kan die lezen zonder dat de components ertussen hem doorgeven.
Voor rendering helpt het om useContext te zien als een subscription op de value van de provider. Verandert de value, dan rendert elk component dat de context leest. De rest blijft met rust.
const SidebarContext = createContext(null)
function SidebarProvider({ children }) {
const [isOpen, setIsOpen] = useState(false)
const value = {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
}
return (
<SidebarContext.Provider value={value}>
{children}
</SidebarContext.Provider>
)
}
Begin met één context en toggle de sidebar. Alle drie de consumers renderen, ook OpenButton, die alleen een functie wil. Een context werkt op de hele value: je abonneert je op het object, niet op een veld erin.
Content rendert niet. Het leest geen context, en het is als children aan de provider meegegeven, dus als de provider opnieuw rendert maakt hij Content niet opnieuw aan. Dat is de composition uit het lijstje hierboven.
Klik nu op de provider. Aan de sidebar verandert niets, maar het value object wordt opnieuw opgebouwd, dus elke consumer rendert voor niets. useMemo lost dat geval op, en alleen dat geval: toggle de sidebar en alles is weer als eerst.
Splitsen doet de rest. De data gaat in de ene context en de functies in de andere. De functies veranderen nooit, dus een component dat alleen de sidebar opent rendert nooit meer opnieuw.
function OpenButton() {
const { open } = useContext(SidebarApiContext)
return <Button onClick={open}>Open</Button>
}
Context is geen gratis global state. Eén grote context koppelt elke consumer aan elke update, en een consumer kan alleen kiezen welke context hij volgt.
07refs
Een ref bewaart ook een waarde tussen renders. Het verschil met state is één ding: een ref aanpassen vertelt React niets, dus er volgt geen render.
function Counter() {
const [count, setCount] = useState(0)
const clicks = useRef(0)
return (
<>
<StateButton
onClick={() => setCount(count + 1)}
/>
<RefButton
onClick={() => { clicks.current += 1 }}
/>
<Screen count={count} clicks={clicks.current} />
</>
)
}
Het gevulde vierkantje is state, het open vierkantje is de ref. Klik op RefButton en de waarde loopt op, maar er licht geen blok op en Screen blijft het oude getal tonen. Pas bij de volgende state update wordt er gerenderd, en dan loopt het scherm weer gelijk.
Dat maakt refs handig en gevaarlijk. De vraag is of de waarde zichtbaar moet zijn in de UI. Zo ja, dan is het state. Zo nee, dan is een ref prima: een timer id, een vorige waarde, een flag in een event handler, een render count. De tellers in deze diagrammen staan om dezelfde reden ook buiten state: een render tellen mag er zelf geen veroorzaken.
Het andere gebruik is de DOM node zelf, voor dingen als focus:
function NameField() {
const inputRef = useRef(null)
return (
<>
<input ref={inputRef} />
<Button onClick={() => inputRef.current?.focus()}>
Focus
</Button>
</>
)
}
State is voor de UI. Refs zijn de escape hatch.
08recap
UI = fn(state)- Een state update voert component functions opnieuw uit.
- Wie de state bezit bepaalt waar het renderen begint.
- Move state down voordat je gaat memoizen.
- Context is een subscription, geen gratis global state.
- Refs bewaren een waarde zonder een render te triggeren.