Tijdens de ontwikkeling hebben we vaak directe interactie met DOM-elementen nodig. In dergelijke gevallen biedt React ons een mechanisme genaamd refs, waarmee toegang tot elementen mogelijk is nadat ze zijn weergegeven. Meestal gebruiken we standaard objectrefs via useRef
(laten we ze zo noemen), maar er is nog een andere benadering die bekend staat als callbackrefs. Deze methode biedt extra flexibiliteit en controle over de levenscyclus van elementen, waardoor we bepaalde specifieke acties kunnen uitvoeren op precieze momenten wanneer elementen aan of losgekoppeld zijn van de DOM.
In dit artikel wil ik uitleggen wat callbackrefs zijn en hoe ze werken, de valkuilen bespreken die je kunt tegenkomen, en voorbeelden van hun gebruik tonen.
Wat Zijn Callbackrefs en Hoe Werken Ze?
Callbackrefs geven je meer gedetailleerde controle over het koppelen van refs in vergelijking met objectrefs. Laten we eens kijken hoe ze in de praktijk werken:
- Monteren. Wanneer een element in de DOM wordt gemonteerd, roept React de ref-functie aan met het DOM-element zelf. Dit stelt je in staat om acties uit te voeren met het element direct nadat het op de pagina verschijnt.
- Demonteren. Wanneer een element wordt gedemonteerd, roept React de ref-functie aan met
null
. Dit geeft je de mogelijkheid om op te ruimen of eventuele acties geassocieerd met dat element te annuleren.
Voorbeeld: Bijhouden van Monteren en Demonteren
import React, { useCallback, useState } from 'react';
function MountUnmountTracker() {
const [isVisible, setIsVisible] = useState(false);
const handleRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Element mounted:', node);
} else {
console.log('Element unmounted');
}
}, []);
return (
<div>
<button onClick={() => setIsVisible((prev) => !prev)}>
{isVisible ? 'Hide' : 'Show'} element
</button>
{isVisible && <div ref={handleRef}>Tracked element</div>}
</div>
);
}
export default MountUnmountTracker;
Elke keer dat we de zichtbaarheid van het element omkeren, wordt de functie handleRef
aangeroepen met ofwel het knooppunt of null
, waardoor we het moment kunnen volgen waarop het element wordt toegevoegd of losgekoppeld.
Veelvoorkomende problemen en oplossingen
Probleem: Herhaalde Aufruf von Callback-Ref
Een veelvoorkomend probleem bij het gebruik van callback-refs is de herhaalde aanmaak van de ref-functie bij elke herrendering van het component. Hierdoor denkt React dat het een nieuwe ref is, roept de oude aan met null
(ruimt het op) en initialiseert vervolgens de nieuwe — zelfs als ons element of component eigenlijk niet is veranderd. Dit kan leiden tot ongewenste bijwerkingen.
Voorbeeld van het probleem
Stel je een Basic
component voor dat een knop heeft om de zichtbaarheid van een div
met een callback-ref te wijzigen, plus een andere knop om een component opnieuw te renderen:
import React, { useState, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = (node: HTMLDivElement | null) => {
console.log('div', node);
};
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
Elke keer dat je op de knop Rerender klikt, wordt het component opnieuw gerenderd, waarbij een nieuwe refCallback
-functie wordt aangemaakt. Als gevolg hiervan roept React de oude refCallback(null)
aan en vervolgens de nieuwe refCallback(node)
, ook al is ons element met de ref niet gewijzigd. In de console zie je achtereenvolgens div null
en vervolgens div [node]
, herhaaldelijk. Natuurlijk willen we onnodige oproepen zoals deze meestal vermijden.
Oplossing: Het memoizen van de Callback-Ref met useCallback
Dit vermijden is vrij eenvoudig: gebruik gewoon useCallback
om de functie te memoizen. Op die manier blijft de functie onveranderd bij herrenders, tenzij de afhankelijkheden veranderen.
import React, { useState, useCallback, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = useCallback((node: HTMLDivElement | null) => {
console.log('div', node);
}, []);
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
Nu wordt refCallback
slechts één keer aangemaakt, bij de initiële rendering. Het zal geen extra oproepen activeren bij volgende her-renderingen, wat onnodige callbacks voorkomt en de prestaties verbetert.
De Volgorde van Callback Refs, useLayoutEffect en useEffect
Voordat we bespreken hoe je callback refs in je code kunt gebruiken om specifieke problemen op te lossen, laten we begrijpen hoe callback refs interageren met de useEffect
en useLayoutEffect
hooks, zodat je de initialisatie en opruiming van bronnen correct kunt organiseren.
Uitvoeringsvolgorde
- callback ref – wordt onmiddellijk na het renderen van DOM-elementen aangeroepen, voor de effect hooks worden uitgevoerd
- useLayoutEffect – wordt uitgevoerd na alle DOM-mutaties maar voor de browser schildert
- useEffect – wordt uitgevoerd nadat de component is voltooid met het renderen naar het scherm
import React, { useEffect, useLayoutEffect, useCallback } from 'react';
function WhenCalled() {
const refCallback = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Callback ref called for div:', node);
} else {
console.log('Callback ref detached div');
}
}, []);
useLayoutEffect(() => {
console.log('useLayoutEffect called');
}, []);
useEffect(() => {
console.log('useEffect called');
}, []);
return (
<div>
<div ref={refCallback}>Element to watch</div>
</div>
);
}
export default WhenCalled;
Console-uitvoer
Callback ref aangeroepen voor div: [div-element]
useLayoutEffect aangeroepen
useEffect aangeroepen
Deze volgorde vertelt ons dat callback refs worden geactiveerd vóór hooks zoals useLayoutEffect
en useEffect
, wat essentieel is om in gedachten te houden bij het schrijven van je logica.
Welke Problemen Oplossen Callback Refs in Code?
Eerst laten we een probleem reproduceren dat typisch wordt aangetroffen met reguliere object refs, zodat we het vervolgens kunnen oplossen met callback refs.
import { useCallback, useEffect, useRef, useState } from 'react';
interface ResizeObserverOptions {
elemRef: React.RefObject<HTMLElement>;
onResize: ResizeObserverCallback;
}
function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
useEffect(() => {
const element = elemRef.current;
if (!element) {
return;
}
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
return () => {
resizeObserver.unobserve(element);
};
}, [onResize, elemRef]);
}
export function UsageDom() {
const [bool, setBool] = useState(false);
const elemRef = useRef<HTMLDivElement>(null);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
useResizeObserver({ elemRef, onResize: handleResize });
const renderTestText = () => {
if (bool) {
return <p ref={elemRef}>Test text</p>;
}
return <div ref={elemRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
We zullen hier niet op elk detail ingaan. Kort gezegd houden we de grootte van ons div
of p
element bij via een ResizeObserver
. In eerste instantie werkt alles prima: bij het monteren kunnen we de grootte van het element krijgen, en ook worden formaataanpassingen gemeld in de console.
De echte problemen beginnen wanneer we de status wijzigen om over te schakelen naar het element dat we observeren. Wanneer we de status veranderen en dus het bijgehouden element vervangen, werkt onze ResizeObserver
niet meer correct. Het blijft het eerste element observeren, dat al uit de DOM is verwijderd! Zelfs terugschakelen naar het oorspronkelijke element helpt niet omdat de abonnement op het nieuwe element nooit correct wordt toegevoegd.
Opmerking: De volgende oplossing is meer representatief voor wat je zou kunnen schrijven in een bibliotheekcontext, waarbij universele code nodig is. In een echt project zou je dit kunnen oplossen door een combinatie van vlaggen, effecten, enz. Maar in bibliotheekcode heb je geen kennis van het specifieke component of de status ervan. Dit is precies het scenario waarin callback-refs ons kunnen helpen.
import { useCallback, useRef, useState } from 'react';
function useResizeObserver(onResize: ResizeObserverCallback) {
const roRef = useRef<ResizeObserver | null>(null);
const attachResizeObserver = useCallback(
(element: HTMLElement) => {
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
roRef.current = resizeObserver;
},
[onResize]
);
const detachResizeObserver = useCallback(() => {
roRef.current?.disconnect();
}, []);
const refCb = useCallback(
(element: HTMLElement | null) => {
if (element) {
attachResizeObserver(element);
} else {
detachResizeObserver();
}
},
[attachResizeObserver, detachResizeObserver]
);
return refCb;
}
export default function App() {
const [bool, setBool] = useState(false);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
const resizeRef = useResizeObserver(handleResize);
const renderTestText = () => {
if (bool) {
return <p ref={resizeRef}>Test text</p>;
}
return <div ref={resizeRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
Zoals je kunt zien, hebben we onze useResizeObserver
hook herschreven om een callback-ref te gebruiken. We geven gewoon een (memoized) callback door die zou moeten worden uitgevoerd bij formaataanpassingen, en ongeacht hoe vaak we elementen omwisselen, blijft onze formaataanpassing nog steeds werken. Dat komt doordat de observer zich hecht aan nieuwe elementen en losmaakt van oude op het exacte moment dat we willen, dankzij de callback-ref.
Het belangrijkste voordeel hier is dat de ontwikkelaar die onze hook gebruikt zich geen zorgen meer hoeft te maken over de logica van het toevoegen/verwijderen van waarnemers onder de motorkap – we hebben die logica ingekapseld in de hook. De ontwikkelaar hoeft alleen een callback door te geven aan onze hook en de geretourneerde ref aan hun elementen te koppelen.
Het combineren van meerdere refs in één
Hier is nog een scenario waarin callback refs te hulp schieten:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={ref} />;
});
export function UsageWithoutCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
In de bovenstaande code hebben we een eenvoudig invoercomponent waarop we een ref instellen, deze halen we uit props met behulp van forwardRef
. Maar hoe gebruiken we inputRef
binnen het Input
-component als we ook de ref van buitenaf nodig hebben voor zaken als focussen? Misschien willen we iets anders doen in het invoercomponent zelf, zoals getBoundingClientRect
. Het vervangen van de propref door onze interne ref betekent dat focussen van buitenaf niet meer werkt. Dus, hoe combineren we deze twee refs?
Dat is waar callback refs opnieuw van pas komen:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
type RefItem<T> =
| ((element: T | null) => void)
| React.MutableRefObject<T | null>
| null
| undefined;
function useCombinedRef<T>(...refs: RefItem<T>[]) {
const refCb = useCallback((element: T | null) => {
refs.forEach((ref) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element;
}
});
}, refs);
return refCb;
}
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
const combinedInputRef = useCombinedRef(ref, inputRef);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={combinedInputRef} />;
});
export function UsageWithCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
Uitleg
We hebben een useCombinedRef
hook geïmplementeerd waar een ontwikkelaar standaard refs, callback refs en optioneel null
of undefined
kan doorgeven. De hook zelf is slechts een useCallback
die over de refs-array loopt. Als het argument null
is, negeren we het, maar als de ref een functie is, roepen we deze aan met het element; als het een standaard ref-object is, stellen we ref.current
in op het element.
Op deze manier fuseren we meerdere refs in één. In het bovenstaande voorbeeld zullen zowel getBoundingClientRect
binnen de Input
component als de externe focusaanroep correct werken.
Wat is er veranderd in React 19 met betrekking tot callback refs?
Automatische Opruiming
React beheert nu automatisch de opruiming van callback refs wanneer elementen ontkoppeld worden, waardoor het beheer van bronnen eenvoudiger wordt. Hier is een voorbeeld dat het React-team in hun documentatie laat zien:
{
// ref aangemaakt
// NIEUW: retourneer een opruimfunctie om
// de ref te resetten wanneer het element uit de DOM wordt verwijderd.
return () => {
// ref opruiming
};
}}
/>
Je kunt meer details hierover lezen in de officiële blogpost, waar wordt vermeld dat binnenkort het opruimen van refs via null
mogelijk verouderd is, waardoor er één gestandaardiseerde manier overblijft om refs op te ruimen.
Kiezen Tussen Normale Refs en Callback Refs
- Gebruik standaard refs (
useRef
) wanneer je gewoon eenvoudige toegang tot een DOM-element nodig hebt of wanneer je een waarde tussen renders wilt behouden zonder extra acties bij het aansluiten of loskoppelen. - Gebruik callback refs wanneer je meer gedetailleerde controle over de levenscyclus van het element nodig hebt, wanneer je universele code schrijft (bijvoorbeeld in je eigen bibliotheek of pakket), of wanneer je meerdere refs samen moet beheren.
Conclusie
Callback-referenties in React zijn een krachtig hulpmiddel dat ontwikkelaars extra flexibiliteit en controle biedt bij het werken met DOM-elementen. In de meeste gevallen zijn standaard objectreferenties via useRef
voldoende voor dagelijkse taken, maar callback-referenties kunnen helpen bij complexere scenario’s zoals die we hierboven hebben besproken.
Source:
https://dzone.com/articles/react-callback-refs-guide