2

A look inside the useEvent polyfill from the new React docs

 1 year ago
source link: https://blog.bitsrc.io/a-look-inside-the-useevent-polyfill-from-the-new-react-docs-d1c4739e8072
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

A look inside the useEvent polyfill from the new React docs

One fine day this summer, React legend, Dan Abramov, published a polyfill for the long awaited useEvent hook. Mind if we take a peek?

1*eqD_5Q2l_l65jRZbSggx2w.png

useEvent polyfill, from the new React docs, as seen in Separating Events from Effects

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:

1*MnwMwiODio3LAo2bKvwvbA.png

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:

Sandbox showing when each effect runs

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:

  1. 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):
  2. 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):
  3. 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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK