18 KiB
title, description, published, authors, tags, attached, license
| title | description | published | authors | tags | attached | license | |||
|---|---|---|---|---|---|---|---|---|---|
| React Refs: The Complete Story | 2020-10-24T05:12:03.284Z |
|
|
cc-by-nc-sa-4 |
Programming terminology can be rather confusing. The first time I'd heard about "React Refs", it was in the context of getting a reference to a DOM node. However, with the introduction of hooks, the useRef hook has expanded the definition of "refs".
Today, we'll be walking through two definitions of refs:
-
A mutable data property to persist data across renders
We'll also be exploring additional functionality to each of those two defintions, such as component refs, adding more properties to a ref, and even exploring common code gotchas associated with using useRef.
As most of this content relies on the
useRefhook, we'll be using functional components for all of our examples. However, there are APIs such asReact.createRefand class instance variables that can be used to recreateReact.useReffunctionality with classes.
Mutable Data Storage
While useState is the most commonly known hook for data storage, it's not the only one on the block. React's useRef hook functions differently from useState, but they're both used for persisting data across renders.
const ref = React.useRef();
ref.current = "Hello!";
In this example, ref.current will contain "Hello!" after the intial render. The returned value from useRef is an object that contains a single key: current.
If you were to run the following code:
const ref = React.useRef();
console.log(ref)
You'd find a {current: undefined} printed to the console. This is the shape of all React Refs. If you look at the TypeScript definition for the hooks, you'll see something like this:
// React.d.ts
interface MutableRefObject {
current: any;
}
function useRef(): MutableRefObject;
Why does useRef rely on storing data inside of a current property? It's so that you can utilize JavaScript's "pass-by-reference" functionality in order to avoid renders.
Now, you might think that the useRef hook is implemented something like the following:
// This is NOT how it's implemented
function useRef(initial) {
const [value, setValue] = useState(initial);
const [ref, setRef] = useState({ current: initial });
useEffect(() => {
setRef({
get current() {
return value;
},
set current(next) {
setValue(next);
}
});
}, [value]);
return ref;
}
However, that's not the case. To quote Dan Apromov:
...
useRefworks more like this:function useRef(initialValue) { const [ref, ignored] = useState({ current: initialValue }) return ref }
Because of this implementation, when you mutate the current value it will not cause a re-render.
Thanks to the lack of rendering on data storage, it's particularly useful for storing data that you need to keep a reference to, but don't need to render on-screen. One such example of this would be a timer:
const dataRef = React.useRef();
const clearTimer = () => {
clearInterval(dataRef.current);
};
React.useEffect(() => {
dataRef.current = setInterval(() => {
console.log("I am here still");
}, 500);
return () => clearTimer();
}, [dataRef]);
Visual Timer with Refs
While there are usages for timers without rendered values, what were to happen if we made the timer render a value in state?
Let's take the example from before, but inside of the setInterval, we update a useState that contains a number to add one to it's state.
const dataRef = React.useRef();
const [timerVal, setTimerVal] = React.useState(0);
const clearTimer = () => {
clearInterval(dataRef.current);
}
React.useEffect(() => {
dataRef.current = setInterval(() => {
setTimerVal(timerVal + 1);
}, 500)
return () => clearInterval(dataRef.current);
}, [dataRef])
return (
<p>{timerVal}</p>
);
Now, we'd expect to see the timer update from 1 to 2 (and beyond) as the timer continues to render. However, if we look at the app while it runs, we'll see some behavior we might not expect:
This is because the closure that's passed to the setInterval has grown stale. This is a common problem when using React Hooks. While there's a simple solution hidden in useState's API, let's solve this problem using mutations and useRef.
Because useRef relies on passing by reference and mutating that reference, if we simply introduce a second useRef and mutate it on every render to match the useState value, we can work around the limitations with the stale closure.
const dataRef = React.useRef();
const [timerVal, setTimerVal] = React.useState(0);
const timerBackup = React.useRef();
timerBackup.current = timerVal;
const clearTimer = () => {
clearInterval(dataRef.current);
};
React.useEffect(() => {
dataRef.current = setInterval(() => {
setTimerVal(timerBackup.current + 1);
}, 500);
return () => clearInterval(dataRef.current);
}, [dataRef]);
- I would not solve it this way in production.
useStateaccepts a callback which you can use as an alternative (much more recommended) route:const dataRef = React.useRef(); const [timerVal, setTimerVal] = React.useState(0); const clearTimer = () => { clearInterval(dataRef.current); }; React.useEffect(() => { dataRef.current = setInterval(() => { setTimerVal(tVal => tVal + 1); }, 500); return () => clearInterval(dataRef.current); }, [dataRef]);We're simply using a
useRefto outline one of the important properties about refs: mutation.
DOM Element References
At the start of this article, I mentioned that refs are not just a mutable data storage method, but a way to reference DOM nodes from inside of React. The easiest of the methods to track a DOM node is by storing it in a useRef hook using any element's ref property:
const elRef = React.useRef();
React.useEffect(() => {
console.log(elRef);
}, [elRef]);
return (
<div ref={elRef}/>
)
Keep in mind, the
refattribute is added and handled by React on any HTML Element. This example uses adiv, but this applies tospans andheaders and beyond, "oh my".
In this example, if we took a look at the console.log in the useEffect, we'd find an HTMLDivElement instance in the current property. Open the following StackBlitz and look at the console value to confirm:
Because elRef.current is now a HTMLDivElement, it means we now have access to the entire Element.prototype JavaScript API. As such, this elRef can be used to style the underlying HTML node:
const elRef = React.useRef();
React.useEffect(() => {
elRef.current.style.background = 'lightblue';
}, [elRef]);
return (
<div ref={elRef}/>
)
Alternative Syntax
It's worth noting that the ref attribute also accepts a function. While we'll touch on the implications of this more in the future, just note that this code example does exaclty the same thing as ref={elRef}:
const elRef = React.useRef();
React.useEffect(() => {
elRef.current.style.background = 'lightblue';
}, [elRef]);
return (
<div ref={ref => elRef.current = ref}/>
)
Component References
HTMLElements are a great use-case for refs. However, there are many instances where you need a ref for an element that's part of a child's render process. How are we able to pass a ref from a parent component to a child component?
By passing a property from the parent to the child, you can pass a ref to a child component. Take an example like this:
const Container = ({children, divRef}) => {
return <div ref={divRef}/>
}
const App = () => {
const elRef = React.useRef();
React.useEffect(() => {
if (!elRef.current) return;
elRef.current.style.background = 'lightblue';
}, [elRef])
return (
<Container divRef={elRef}/>
);
You might be wondering why I didn't call that property ref instead of divRef. This is because of a limitations with React. If we try to switch the property's name to ref, we find ourselves with some unintended concequences.
// This code does not function as intended
const Container = ({children, ref}) => {
return <div ref={ref}/>
}
const App = () => {
const elRef = React.useRef();
React.useEffect(() => {
if (!elRef.current) return;
// If the early return was not present, this line would throw an error:
// "Cannot read property 'style' of undefined"
elRef.current.style.background = 'lightblue';
}, [elRef])
return (
<Container ref={elRef}/>
);
You'll notice that the Container div is not styled to have a lightblue background. This is because elRef.current is never set to contain the HTMLElement ref. As such, for simple ref forwarding, you cannot use the ref property name.
How do you get the ref property name to work as expected with functional components?
You can use the ref property name to forward refs by using the forwardRef API. When defining a functional component, instead of simply being an arrow function, you pass forwardRef with the arrow function as it's first property. From there, you can access ref from the second property of the inner arrow function.
const Container = React.forwardRef((props, ref) => {
return <div ref={ref}>{props.children}</div>
})
const App = () => {
const elRef = React.useRef();
React.useEffect(() => {
console.log(elRef);
elRef.current.style.background = 'lightblue';
}, [elRef])
return (
<Container ref={elRef}/>
);
Now that we are using forwardRef, we can use the ref property name on the parent component to get access to the elRef once again.
Add Data to Ref
import React from "react";
import "./style.css";
const Container = React.forwardRef(({children}, ref) => {
return <div ref={ref} tabIndex="1">
{children}
</div>
})
export default function App() {
const elRef = React.useRef();
React.useEffect(() => {
elRef.current.focus();
}, [elRef])
return (
<Container ref={elRef}>
<h1>Hello StackBlitz!</h1>
<p>Start editing to see some magic happen :)</p>
</Container>
);
}
https://stackblitz.com/edit/react-use-imperative-handle-demo-pre
- useImperativeHandle hook allows properties to be added to ref
- Can be used in combination with forwardRef
- Only properties returned in second param are set to ref
import React from "react";
import "./style.css";
const Container = React.forwardRef(({children}, ref) => {
const divRef = React.useRef();
React.useImperativeHandle(ref, () => ({
focus: () => {
divRef.current.focus();
console.log("I have now focused");
}
}))
return <div ref={divRef} tabIndex="1">
{children}
</div>
})
export default function App() {
const elRef = React.useRef();
React.useEffect(() => {
elRef.current.focus();
}, [elRef])
return (
<Container ref={elRef}>
<h1>Hello StackBlitz!</h1>
<p>Start editing to see some magic happen :)</p>
</Container>
);
}
https://stackblitz.com/edit/react-use-imperative-handle-demo-post
Be cautious about using this in production. It breaks unidirectional data binding
React.useEffect(() => {
elRef.current.konami();
}, [elRef])
https://stackblitz.com/edit/react-use-imperative-handle-demo-useful
React Refs in useEffect
useEffectonly does the array check on re-render- Ref's current property set doesn't trigger a re-render
export default function App() {
const elRef = React.useRef();
React.useEffect(() => {
elRef.current.style.background = "lightblue";
}, [elRef]);
return (
<div ref={elRef}>
<h1>Hello StackBlitz!</h1>
<p>Start editing to see some magic happen :)</p>
</div>
);
}
https://stackblitz.com/edit/react-use-ref-effect-style
However, what happens when you make the div render happen after the initial render. What do you think will happen here?
export default function App() {
const elRef = React.useRef();
const [shouldRender, setRender] = React.useState(false);
React.useEffect(() => {
if (!elRef.current) return;
elRef.current.style.background = 'lightblue';
}, [elRef.current])
React.useEffect(() => {
setTimeout(() => {
setRender(true);
}, 100);
}, []);
return !shouldRender ? null : (
<div ref={elRef}>
<h1>Hello StackBlitz!</h1>
<p>Start editing to see some magic happen :)</p>
</div>
);
}
https://stackblitz.com/edit/react-use-ref-effect-bug-effect
const [minus, setMinus] = React.useState(0);
const ref = React.useRef(0);
const addState = () => {
setMinus(minus + 1);
};
const addRef = () => {
ref.current = ref.current + 1;
};
React.useEffect(() => {
console.log(`ref.current:`, ref.current);
}, [ref.current]);
React.useEffect(() => {
console.log(`minus:`, minus);
}, [minus]);
https://stackblitz.com/edit/react-use-ref-not-updating
Here are some comments from Dan Apromov, of the React Core team:
https://github.com/facebook/react/issues/14387#issuecomment-503616820
https://twitter.com/dan_abramov/status/1093497348913803265
https://github.com/facebook/react/issues/14387#issuecomment-493677168
But what does Dan mean by "callback ref"?
Callback Refs
- Remember, "ref" property accepts a function to access the node
- Because it's not using "useEffect", code can execute in proper timing
const elRefCB = React.useCallback(node => {
if (node !== null) {
node.style.background = "lightblue";
}
}, []);
return !shouldRender ? null : (
<div ref={elRefCB}>
<h1>Hello StackBlitz!</h1>
<p>Start editing to see some magic happen :)</p>
</div>
);
https://stackblitz.com/edit/react-use-ref-callback-styling
- Can still keep a traditional "useRef" reference
const elRef = React.useRef();
console.log("I am rendering");
const elRefCB = React.useCallback(node => {
if (node !== null) {
node.style.background = "lightblue";
elRef.current = node;
}
}, []);
React.useEffect(() => {
console.log(elRef.current);
}, [elRef, shouldRender]);
https://stackblitz.com/edit/react-use-ref-callback-and-effect
useState Refs
- Combining useState and Callback Refs
- Will trigger a re-render
- Works in useEffect
const [elRef, setElRef] = React.useState();
console.log('I am rendering');
const elRefCB = React.useCallback(node => {
if (node !== null) {
setElRef(node);
}
}, []);
React.useEffect(() => {
console.log(elRef);
}, [elRef])
https://stackblitz.com/edit/react-use-ref-callback-and-use-state
- Can be used to impact reference using useEffect instead of inside of callback
const [elNode, setElNode] = React.useState();
const elRefCB = React.useCallback(node => {
if (node !== null) {
setElNode(node);
}
}, []);
React.useEffect(() => {
if (!elNode) return;
elNode.style.background = 'lightblue';
}, [elNode])
https://stackblitz.com/edit/react-use-ref-callback-and-state-effect