This content originally appeared on Bits and Pieces - Medium and was authored by Guillaume Renard
One fine day this summer, React legend, Dan Abramov, published a polyfill for the long awaited useEvent hook. Mind if we take a peek?
A bit of context
If you haven’t been following the ‘news’ lately, you might have missed the RFC for useEvent. Long story short, here is what the React team has to say about it:
We suspect that useEvent is a fundamental missing piece in the Hooks programming model and that it will provide the correct way to fix overfiring effects without error-prone hacks like skipping dependencies.
Indeed, before the introduction of useEvent, you would have struggled to write certain effects without having to omit dependencies from the array or to make compromises on the desired behaviour.
Take this example from the RFC. The goal is to log analytics whenever the user visits a page:
function Page({ route, currentUser }) {
useEffect(() => {
logAnalytics('visit_page', route.url, currentUser.name);
}, [route.url, currentUser.name]);
// ...
}
When the route changes, an event is logged with the route URL and the name of the user ✅. Now imagine that the user is on the Profile page and she decides to edit her name. The effect runs again and logs a new entry, which is not what we wanted 🔴.
With useEvent, you can extract an event from the effect:
function Page({ route, currentUser }) {
// ✅ Stable identity
const onVisit = useEvent(visitedUrl => {
logAnalytics('visit_page', visitedUrl, currentUser.name);
});
useEffect(() => {
onVisit(route.url);
}, [route.url]); // ✅ Re-runs only on route change
// ...
}
Logging analytics is now done in response to an event, which is triggered by the change of route.
The event handler (onVisit) is created via useEvent, which returns a stable function. This means that even if the component re-renders, the function returned by useEvent will always be the same (same identity). And because of that, it no longer needs to be passed as a dependency to useEffect 👐
There are other examples and cool stuff about useEvent that you can read about in the RFC itself, such as wrapping events at the usage site. But as I’m writing this, useEvent is still a work in progress. So until it ships, people will still be wondering whether it’s safe or not to omit dependencies from their dependency arrays…
….or they could start using the shim that Dan Abramov published in the new React docs😮
A shim was born
If you really haven’t been following the ‘news’, then you probably missed the fact that the React team has been busy rewriting their documentation website this year (if you’re from the future, it’s year 2022 here).
It’s still in beta, but it’s already way better than the old one. I wish all docs were as good as this one. And the reason I keep mentioning Dan Abramov is that he’s the main author (long live the king). So go check it out.
Effects have a special part in these new docs. Maybe because people, including myself, have been using them wrong (or overusing them) since their release in React 16.8. Or maybe because people started moaning when they noticed that their 1,000 effects started running twice in StrictMode after upgrading to React 18.
So, it’s no surprise that you’ll find as many as 5 pages dedicated to effects in the new docs! You will also learn, in length, how useEvent can save you from dependency hell. But as you get to it, you’ll stumble over one of these pitfalls:
Luckily, one glorious day of this burning hot summer, a polyfill was added to the examples and challenges, without much explanation, apart from this large disclaimer:
Interesting. I don’t know about you, but I can’t resist taking a peek at the code inside the shim. Even if it’s just a temporary one that I’m not expected to be writing myself! How about you?
I thought so.
Then let’s start at line 7, where the useEvent shim is declared. As expected, the hook receives a callback function called fn in argument, just like the one in the RFC:
export function useEvent(fn) {
Next, a reference is declared with useRef which contains the ‘null’ value initially (line 8):
const ref = useRef(null);
The interesting part comes next: the reference (ref) is set from an effect than runs whenever fn changes (lines 9–11):
useInsertionEffect(() => {
ref.current = fn;
}, [fn]);
It’s not any kind of effect: the React team chose to use an insertion effect, which was introduced in React 18.
If you don’t know, there are several flavours of effects in React: normal effects (triggered by useEffect), layout effects (useLayoutEffect) and insertion effects (useInsertionEffect).
Each of them fires at different stages in the component lifecycle. First come insertion effects (before DOM mutations are applied), then come layout effects (after the DOM is updated) and finally come normal effects (after the component has finished rendering),
The sandbox below prints a message to the console each time these effects are triggered. I’ve also added a log to show when DOM references are set by React:
You should see the following output in the console:
> useInsertionEffect
> setRef
> useLayoutEffect
> useEffect
This is in line with what we said earlier. We can also see that insertion effects trigger about the same time as when React sets references, and more importantly, before layout effects.
The detailed design in the RFC specifies that:
The “current” version of the [event] handler is switched before all the layout effects run. This avoids the pitfall present in the userland versions where one component’s effect can observe the previous version of another component’s state. The exact timing of the switch is an open question though (listed with other open questions at the bottom).
It now makes sense why the reference is set on an insertion effect rather than any other kind of effect. Code running inside layout effects or later expects to call an updated reference. So the reference needs to be updated first.
Using an insertion effect is of course not bulletproof. One could try to use the event handler in another insertion effect. In that case, the reference might not be up to date yet. This is why useEvent cannot be implemented safely in userland. The future implementation inside React will solve that.
But let’s go back to the shim. I’ll paste it one more time so it’s easier to follow:
The last part concerns the function returned by useCallback (lines 12–15):
return useCallback((...args) => {
const f = ref.current;
return f(...args);
}, []);
That callback doesn’t have any dependencies [] (line 15), so it is only created once. As a result, useCallback always returns the same function. And because of that, the shim returns a stable function which satisfies the spec.
Now for the callback itself. We see that:
- return useCallback((...args)=> {
it takes a variable list of arguments (the code isn’t making any assumptions on the number of arguments the handler accepts): - const f = ref.current;
it accesses the current value of the ref, which contains the latest fn function (thanks to the code in the effect line 10): - return f(...args);
finally, it calls that function, forwarding the arguments received
And here we have a stable event handler that is always up to date! And since the event handler is stable, it doesn’t matter whether you include it or not in the dependency array of your effect: it will never cause the effect to run again on its own.
But why does it work?
Yeah, I’m pretty sure that it’s still not obvious to everyone why this actually works. How can the event handler be always ‘up to date’? And by up to date, I don’t just mean that its reference is up to date, but also that it can access ‘fresh’ values when it runs.
⚠ Spoiler: it has to do with closures.
Let’s go back to the example from the RFC:
function Page({ route, currentUser }) {
// ✅ Stable identity
const onVisit = useEvent(visitedUrl => {
logAnalytics('visit_page', visitedUrl, currentUser.name);
});
useEffect(() => {
onVisit(route.url);
}, [route.url]); // ✅ Re-runs only on route change
// ...
}
Why is it that when the effect calls onVisit, currentUser.name is up to date, even though we didn’t specify it as a dependency anywhere?
Well, each time the component renders, we call useEvent with a new arrow function visitedUrl => { … }. That function accesses currentUser.name, which is defined higher up in the component’s scope. This is what we call a closure. Because of that, the function ‘captures’ the value of currentUser.name at the time the component is rendered.
Since we’re using React, we know that the component re-renders whenever its props change. That’s why we have a new up to date function each time the component renders, which useEvent takes care of storing in its ref. Then, whenever the event handler (onVisit) is called, the code invokes the function stored in the ref, the one that ‘captured’ the latest value in the component.
It’s easier to understand when you try to substitute the properties with their values:
1st render
Say that the component is rendered with:
Page({
route: { url: '/profile' },
currentUser: { name: 'Dan' },
});
When it happens, you can imagine that useEvent is called with a function where currentUser.name is replaced with Dan:
const onVisit = useEvent(visitedUrl => {
logAnalytics('visit_page', visitedUrl, 'Dan');
});
In this representation, visitedUrl => { logAnalytics(’visit_page’, visitedUrl, 'Dan’); } is what gets stored inside the ref in useEvent.
So, when the effect calls onVisit with the route.url it depends on, logAnalytics is actually called with these values:
logAnalytics('visit_page', '/profile', 'Dan');
2nd render
Now imagine than Dan changes his name to ‘Rick’ (sorry Dan). React re-renders the component with:
Page({
route: { url: '/profile' },
currentUser: { name: 'Rick' },
});
useEvent is called again, this time with a function where currentUser.name is substituted with Rick (the updated value):
const onVisit = useEvent(visitedUrl => {
logAnalytics('visit_page', visitedUrl, 'Rick');
});
useEvent updates its ref again with visitedUrl => { logAnalytics('visit_page', visitedUrl, 'Rick'); }.
But since route.url hasn’t changed, the effect does not run, and therefore onVisit is not called. No analytics are logged.
3rd render
Then, ̶D̶a̶n̶ Rick navigates to the home page. The component is rendered again with:
Page({
route: { url: '/home' },
currentUser: { name: 'Rick' },
});
useEvent is called yet again with a function where currentUser.name is substituted with Rick:
const onVisit = useEvent(visitedUrl => {
logAnalytics('visit_page', visitedUrl, 'Rick');
});
Despite the value of currentUser.name being the same as earlier (‘Rick’), the function passed to useEvent is still a new function strictly speaking. They are different instances, so they have different identities (Object.is would return false if we compared the function with the one from the previous render). So useEvent updates its ref again. And we don’t care! The overhead is negligible.
Finally, the effect runs again since its dependency (route.url) has changed. Which means that onVisit is called with /home this time, which in turns calls logAnalytics with:
logAnalytics('visit_page', '/home', 'Rick');
Just as you would expect!
As a side note, it is interesting to note that, in this example, the route URL is passed in parameter to the onVisit event, rather than being referenced directly inside the handler (like we did with the currentUser.name property). It’s an important distinction, because it means that we’ll be logging the route URL at the time the effect runs. If we had used logAnalytics(’visit_page’, route.url, currentUser.name) in the event handler, we would always be logging the latest value of the route URL.
In this particular case, it doesn’t make much difference since the code in the effect is synchronous. But if onVisit had been called in response to an asynchronous method, the value of the route URL passed to the function would be the one at the time the effect ran, which might no longer be the latest route.url.
This is explained in details in reading latest props and state with Event functions.
That’s it!
If you liked what you read, don’t hesitate to follow me for more!
Go composable: Build apps faster like Lego
Bit is an open-source tool for building apps in a modular and collaborative way. Go composable to ship faster, more consistently, and easily scale.
Build apps, pages, user-experiences and UIs as standalone components. Use them to compose new apps and experiences faster. Bring any framework and tool into your workflow. Share, reuse, and collaborate to build together.
Help your team with:
Learn more
- How We Build Micro Frontends
- How we Build a Component Design System
- The Bit Blog
- 5 Ways to Build a React Monorepo
- How to Create a Composable React App with Bit
A look inside the useEvent polyfill from the new React docs was originally published in Bits and Pieces on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Bits and Pieces - Medium and was authored by Guillaume Renard
Guillaume Renard | Sciencx (2022-09-20T07:47:59+00:00) A look inside the useEvent polyfill from the new React docs. Retrieved from https://www.scien.cx/2022/09/20/a-look-inside-the-useevent-polyfill-from-the-new-react-docs/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.