Store a callback in useRef() - javascript

Here is an example of a mutable ref storing the current callback from the Overreacted blog:
function useInterval(callback, delay) {
const savedCallback = useRef();
// update ref before 2nd effect
useEffect(() => {
savedCallback.current = callback; // save the callback in a mutable ref
});
useEffect(() => {
function tick() {
// can always access the most recent callback value without callback dep
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
However the React Hook FAQ states that the pattern is not recommended:
Also note that this pattern might cause problems in the concurrent mode. [...]
In either case, we don’t recommend this pattern and only show it here for completeness.
I found this pattern to be very useful in particular for callbacks and don't understand why it gets a red flag in the FAQ. For example, a client component can use useInterval without needing to wrap useCallback around the callback (simpler API).
Also there shouldn't be a problem in concurrent mode, as we update the ref inside useEffect. From my point of view, the FAQ entry might have a wrong point here (or I have misunderstood it).
So, in summary:
Does anything fundamentally speak against storing callbacks inside mutable refs?
Is it safe in concurrent mode when done like it is in the above code, and if not, why not?

Minor disclaimer: I'm not a core react dev and I haven't looked at the react code, so this answer is based on reading the docs (between the lines), experience, and experiment
Also this question has been asked since which explicitly notes the unexpected behaviour of the useInterval() implementation
Does anything fundamentally speak against storing callbacks inside mutable refs?
My reading of the react docs is that this is not recommended but may still be a useful or even necessary solution in some cases hence the "escape hatch" reference, so I think the answer is "no" to this. I think it is not recommended because:
you are taking explicit ownership of managing the lifetime of the closure you are saving. You are on your own when it comes to fixing it when it gets out of date.
this is easy to get wrong in subtle ways, see below.
this pattern is given in the docs as an example of how to work around repeatedly rendering a child component when the handler changes, and as the docs say:
it is preferable to avoid passing callbacks deep down
by e.g. using a context. This way your children are less likely to need re-rendering every time your parent is re-rendered. So in this use-case there is a better way to do it, but that will rely on being able to change the child component.
However, I do think doing this can solve certain problems that are difficult to solve otherwise, and the benefits from having a library function like useInterval() that is tested and field-hardened in your codebase that other devs can use instead of trying to roll their own using setInterval directly (potentially using global variables... which would be even worse) will outweigh the negatives of having used useRef() to implement it. And if there is a bug, or one is introduced by an update to react, there is just one place to fix it.
Also it might be that your callback is safe to call when out of date anyway, because it may just have captured unchanging variables. For example, the setState function returned by useState() is guaranteed not to change, see the last note in this, so as long as your callback is only using variables like that, you are sitting pretty.
Having said that, the implementation of setInterval() that you give does have a flaw, see below, and for my suggested alternative.
Is it safe in concurrent mode, when done like in above code (if not, why)?
Now I don't exactly know how concurrent mode works (and it's not finalized yet AFAIK), but my guess would be that the window condition below may well be exacerbated by concurrent mode, because as I understand it it may separate state updates from renders, increasing the window condition that a callback that is only updated when a useEffect() fires (i.e. on render) will be called when it is out of date.
Example showing that your useInterval may pop when out of date.
In the below example I demonstrate that the setInterval() timer may pop between setState() and the invocation of the useEffect() which sets the updated callback, meaning that the callback is invoked when it is out of date, which, as per above, may be OK, but it may lead to bugs.
In the example I've modified your setInterval() so that it terminates after some occurrences, and I've used another ref to hold the "real" value of num. I use two setInterval()s:
one simply logs the value of num as stored in the ref and in the render function local variable.
the other periodically updates num, at the same time updating the value in numRef and calling setNum() to cause a re-render and update the local variable.
Now, if it were guaranteed that on calling setNum() the useEffect()s for the next render would be immediately called, we would expect the new callback to be installed instantly and so it wouldn't be possible to call the out of date closure. However the output in my browser is something like:
[Log] interval pop 0 0 (main.chunk.js, line 62)
[Log] interval pop 0 1 (main.chunk.js, line 62, x2)
[Log] interval pop 1 1 (main.chunk.js, line 62, x3)
[Log] interval pop 2 2 (main.chunk.js, line 62, x2)
[Log] interval pop 3 3 (main.chunk.js, line 62, x2)
[Log] interval pop 3 4 (main.chunk.js, line 62)
[Log] interval pop 4 4 (main.chunk.js, line 62, x2)
And each time the numbers are different illustrates the callback has been called after the setNum() has been called, but before the new callback has been configured by the first useEffect().
With more trace added the order for the discrepancy logs was revealed to be:
setNum() is called,
render() occurs
"interval pop" log
useEffect() updating ref is called.
I.e. the timer pops unexpectedly between the render() and the useEffect() which updates the timer callback function.
Obviously this is a contrived example, and in real life your component might be much simpler and not actually be able to hit this window, but it's at least good to be aware of it!
import { useEffect, useRef, useState } from 'react';
function useInterval(callback, delay, maxOccurrences) {
const occurrencesRef = useRef(0);
const savedCallback = useRef();
// update ref before 2nd effect
useEffect(() => {
savedCallback.current = callback; // save the callback in a mutable ref
});
useEffect(() => {
function tick() {
// can always access the most recent callback value without callback dep
savedCallback.current();
occurrencesRef.current += 1;
if (occurrencesRef.current >= maxOccurrences) {
console.log(`max occurrences (delay ${delay})`);
clearInterval(id);
}
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
function App() {
const [num, setNum] = useState(0);
const refNum = useRef(num);
useInterval(() => console.log(`interval pop ${num} ${refNum.current}`), 0, 60);
useInterval(() => setNum((n) => {
refNum.current = n + 1;
return refNum.current;
}), 10, 20);
return (
<div className="App">
<header className="App-header">
<h1>Num: </h1>
</header>
</div>
);
}
export default App;
Alternative useInterval() that does not have the same problem.
The key thing with react is always to know when your handlers / closures are being called. If you use setInterval() naively with arbitrary functions then you are probably going to have trouble. However, if you ensure your handlers are only called when the useEffect() handlers are called, you will know that they are being called after all state updates have been made and you are in a consistent state. So this implementation does not suffer in the same way as the above one, because it ensures the unsafe handler is called in useEffect(), and only calls a safe handler from setInterval():
import { useEffect, useRef, useState } from 'react';
function useTicker(delay, maxOccurrences) {
const [ticker, setTicker] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTicker((t) => {
if (t + 1 >= maxOccurrences) {
clearInterval(timer);
}
return t + 1;
}), delay);
return () => clearInterval(timer);
}, [delay]);
return ticker;
}
function useInterval(cbk, delay, maxOccurrences) {
const ticker = useTicker(delay, maxOccurrences);
const cbkRef = useRef();
// always want the up to date callback from the caller
useEffect(() => {
cbkRef.current = cbk;
}, [cbk]);
// call the callback whenever the timer pops / the ticker increases.
// This deliberately does not pass `cbk` in the dependencies as
// otherwise the handler would be called on each render as well as
// on the timer pop
useEffect(() => cbkRef.current(), [ticker]);
}

Related

React re-execute inner useState function after each re-render

Edit
Okay my bad, I guess the response is simple. It's a javascript matter. I am not passing a function to useState but actually I am re-executing it everytime. So yeah what I am seeing is normal.
I will let my question here anyway.
A codesandbox example will follow.
Question :
Could someone please explain to me why React re-execute the inner function in a useState (or useRef) despite the returned value will be completely ignored (except the first execution) ? Is this the expected behaviour of useState ?
Explanation :
I mean why I see this console log here after each re-render
const [count, setCount] = React.useState((function(){
console.log('Executed !');
return 5;
})())
I used to think this inner function (or values) is the equivalent of a Class Parameters or Component Constructor but it's obviously not the case.
Returning a simple value does the job of course : React.useState(5).
But imagine using a third-party class which works with static values at some point or you simply don't want to re-execute it over and over again after each re-render ?
A possible fix for this is using useEffect with an empty dependency. (The equivalent of componentDidMount)
But I am asking about the purpose of this execution ?
let count = 0; // Number of component renders
const Test = (props) => {
const [innerCount, setCount] = React.useState((function(){})(
console.log('Executed !'); // Executed after each re-render
count = count + 1;
return count;
));
return innerCount; // Will be always 1 !
}
Why the anonymous function will be executed if the returned value is always 1 = the first returned value ?! What's the purpose of this execution ?
This is an example on CodeSandbox
This isn't React-specific behaviour, it's just what Javascript - and almost every mainstream programming language - does.
Before calling a function, it has to evaluate its arguments in order to know what to pass in.
Yes, it so happens that React.useState is a function that often "ignores" its argument. (I'm not familiar with precisely how it's implemented - clearly it isn't as simple as that as it does need to know when it's the first time it's being executed in that particular component instance, because in that case that value is used and returned.) But the browser Javascript engine can't possibly know that or optimise it away.
To prove this isn't anything to do with React, I can do this:
function ignoresArgument(x) {
return 1;
}
for (let i = 0; i < 20; i++) {
ignoresArgument(
(function(y) {
console.log("I don't need to be executed, but Javascript doesn't know that so I will be executed 20 times");
return 2;
})(1)
);
}
In addition to all the other answers, I think this link addresses your issue where you only want to call the function in useState once.
You can change this line in the sandbox:
const [dependency] = React.useState(() => new ThirdParty());
and the third party counter would stop incrementing.
I think your understanding of useEffect is somewhat wrong. It is not just used for componentDidMount. What you are describing is a symptom of a side-effect that cases state changes in the components. Here is the explanation of useEffect from the official documentation.
If you’re familiar with React class lifecycle methods, you can think
of useEffect Hook as componentDidMount, componentDidUpdate, and
componentWillUnmount combined.
useEffect would be ideal for this situation if you want to avoid re-renders that is not required.
React memo might not be needed for your solution either.

Do react query result objects go stale due to closures?

I have always felt like I have a pretty solid understanding of how JS closures work, but since using ReactJS for the last couple of months there is something I can't quite make sense of.
In the below example, will the query result object getData be stale if used in the success callback for the other query?
My closure instincts tell me yes, but my reactjs instincts tell me no for some reason and I can't make sense of why it works the way that it does.
All help is appreciated!
/*
* Custom query hooks
*/
function useGetData(someId, opts){
return useQuery(['getData', someId],
getDataQueryFn,
{
...opts,
},
);
}
function useOtherData(someId, opts){
return useQuery(['otherData', someId],
otherDataQueryFn,
{
...opts,
},
);
}
/*
* React component
*/
function MyComponent(){
const getData = useGetData("1");
const otherData = useOtherData("1", {
onSuccess: () => {
// QUESTION: if I use getData here, won't it be stale due to closure? why or why not?
}
});
// render and stuff...
}
It doesn't go stale. Here's a codesandbox that closures over a counter and the fetch needs 5 seconds to complete. relevant code from the sandbox:
const [count, inc] = React.useReducer((val) => val + 1, 0);
const { data } = useQuery(
"repoData",
async () => {
await waitFor(5000);
return Promise.resolve({ name: "foo " });
},
{
onSuccess: () => console.log(count)
}
);
if you click the counter a couple of times in the first 5 seconds, the latest count will still be logged. It doesn't really matter if you closure over local state or over some other query.
The reason is likely that react-query will only trigger a re-render after the Promise is successful (it's a simple forced re-render via useState under the hood), and at that time, react already "sees" the latest value, not the value from the time when the query started to fetch.
It depends on useQuery implementation. However I am pretty sure it wont use stale data. I expect following order:
Each time result of useGetData is updated - re-render happens, due to some internal state update
new onSuccess callback (capturing latest getData result in closure) is passed via props to useOtherData and picked up by library to handle next update

Potential bug in "official" useInterval example

useInterval
useInterval from this blog post by Dan Abramov (2019):
function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
A Potential Bug
The interval callback may be invoked between the commit phase and the useEffect invocation, causing the old (and hence not up to date) callback to be called. In other words, this may be the execution order:
Render phase - a new value for callback.
Commit phase - state committed to DOM.
useLayoutEffect
Interval callback - using savedCallback.current(), which is different than callback.
useEffect - savedCallback.current = callback;
React's Life Cycle
To further illustrate this, here's a diagram showing React's Life Cycle with hooks:
Dashed lines mean async flow (event loop released) and you can have an interval callback invocation at these points.
Note however, that the dashed line between Render and React updates DOM (commit phase) is most likely a mistake. As this codesandbox demonstrates, you can only have an interval callback invoked after useLayoutEffect or useEffect (but not after the render phase).
So you can set the callback in 3 places:
Render - Incorrect because state changes have not yet been committed to the DOM.
useLayoutEffect - correct because state changes have been committed to the DOM.
useEffect - incorrect because the old interval callback may fire before that (after layout effects) .
Demo
This bug is demonstrated in this codesandebox. To reproduce:
Move the mouse over the grey div - this will lead to a new render with a new callback reference.
Typically you'll see the error thrown in less than 2000 mouse moves.
The interval is set to 50ms, so you need a bit of luck for it to fire right between the render and effect phases.
Use Case
The demo shows that the current callback value may differ from that in useEffect alright, but the real question is which one of them is the "correct" one?
Consider this code:
const [size, setSize] = React.useState();
const onInterval = () => {
console.log(size)
}
useInterval(onInterval, 100);
If onInterval is invoked after the commit phase but before useEffect, it will print the wrong value.
This does not look like a bug to me, although I understand the discussion.
The answer above that suggests updating the ref during render would be a side effect, which should be avoided because it will cause problems.
The demo shows that the current callback value may differ from that in useEffect alright, but the real question is which one of them is the "correct" one?
I believe the "correct" one is the one that has been committed. For one reason, committed effects are the only ones that are guaranteed to have cleanup phase later. (The interval in this question doesn't need a cleanup effect, but other things might.)
Another more compelling reason in this case, perhaps, is that React may pre-render things (either at lower priority, or because they're "offscreen" and not yet visible, or in the future b'c of animation APIs). Pre-rendering work like this should never modify a ref, because the modification would be arbitrary. (Consider a future animation API that pre-renders multiple possible future visual states to make transitions faster in response to user interaction. You wouldn't want the one that happened to render last to just mutate a ref that's used by your currently visible/committed view.)
Edit 1 This discussion mostly seems to be pointing out that when JavaScript isn't synchronous (blocking), when it yields between rendering, there's a chance for other things to happen in between (like a timer/interval that was previously scheduled). That's true, but I don't think it's a bug if this happens during render (before an update is "committed").
If the main concern is that the callback might execute after the UI has been committed and mismatch what's on the screen, then you might want to consider useLayoutEffect instead. This effect type is called during the commit phase, after React has modified the DOM but before React yields back to the browser (aka so no intervals or timers can run in between).
Edit 2 I believe the reason Dan originally suggested using a ref and an effect for this (rather than just an effect) was because updates to the callback wouldn't reset the interval. (If you called clearInterval and setInterval each time the callback changed, the overall timing would be interrupted.)
To attempt to answer your final question strictly:
I can't see any logical harm updating the callback in render() as opposed to useEffect(). useEffect() is never called anything other than after render(), and whatever it is called with will be what the last render was called with, so the only difference logically is that the callback may be more out-of-date by the time the useEffect() is called.
This may be exacerbated by the coming concurrent mode, if there may be multiple calls to render() before a call to useEffect(), but I'm not even sure it works like that.
However: I would say there is a maintenance cost to doing it this way: it implies that it is ok to cause side effects in render(). In general that is not a good idea, and all necessary side effects should really be done in useEffect(), because, as the docs say:
the render method itself shouldn’t cause side effects ... we typically want to perform our effects after React has updated the DOM
So I would recommend putting any side effect inside a useEffect() and having that as a coding standard, even if in certain situations it is OK. And particularly in a blog post by a react core dev that is going to be copied and pasted by "guide" many people it is important to set the right example ;-P
Alternative solution
As for how you can fix your problem, I will just copy and paste my suggested implementation of setInterval() from this answer, which should remove the ambiguity by calling the callback in a separate useEffect(), at which point all state should be consistent and you don't have to worry about which is "correct". Dropping it into your sandbox seemed to solve the problem.
function useTicker(delay) {
const [ticker, setTicker] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTicker(t => t + 1), delay);
return () => clearInterval(timer);
}, [delay]);
return ticker;
}
function useInterval(cbk, delay) {
const ticker = useTicker(delay);
const cbkRef = useRef();
// always want the up to date callback from the caller
useEffect(() => {
cbkRef.current = cbk;
}, [cbk]);
// call the callback whenever the timer pops / the ticker increases.
// This deliberately does not pass `cbk` in the dependencies as
// otherwise the handler would be called on each render as well as
// on the timer pop
useEffect(() => cbkRef.current(), [ticker]);
}
Here is a modification of your example which shows that both/neither approaches are correct: https://codesandbox.io/s/useintervalbug-neither-are-correct-zu2zt?file=/src/App.js
The use of refs is not what you would do in reality but it was necessary to easily detect and report the problem. They do not materially affect the behaviour.
In this example the parent component has created the new "correct" callback, finished rendering and wants that new callback to be used by the child and the timer.
Ultimately there is a race condition between when the "correct" callback ultimately gets passed to useInterval and when the browser decides to call the callback. I do not think it is possible to avoid this.
It makes no difference if you memoize the callback unless of course it has no dependencies and never changes.

State not updating inside recursion

export default function App() {
const [actionId, setActionId] = useState("");
const startTest = async () => {
const newActionId = actionId + 1;
setActionId(newActionId);
const request = {
actionId: newActionId
}
console.log({ request });
// const response = await api.runTests(request)
await new Promise((resolve) => setTimeout(resolve, 4000));
startTest();
};
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<button onClick={startTest}>Start</button>
</div>
);
}
request actionId is always 1, though I changed it every 4 seconds.
I know setState is async, but it's weird that the state is not updated after 4 seconds.
Theory
A rendered React component contains functions that belong to that rendering. If these functions reference stale variables, this will affect the result. Search for stale closures to find out more about this. It is particularly easy to end up with stale closures when dealing with the traditional Javascript functions setTimeout and setInterval in React.
So what's going on?
So on your first render, a particular instance of startTest exists. When you click the button, THAT instance of startTest runs. This instance of startTest contains a closure of actionId (by the way, why do you set actionId to "" and then do addition to this? Would be more expected to start with 0 or do addition by + "1"). When the state is set, React rerenders with actionId set to "1" and with a new version of startTest containing a closure where actionId is "1". However, this function would only be triggered if you click the button anew. Instead, what happens is that the timeout from the first render triggers a new call to the old startTest from the first render. This timeout does not know that the component has rerendered and that there is a new version of startTest some place else with an updated closure of actionId. When the function is retriggered, it calculates the same value as the first time for newActionId and so we are trapped in a loop which only uses that very first instance of startTest containing a stale closure.
How to solve it?
If you want to use timeouts and intervals in React, you gotta do it the React way. There are probably packages out there for this but if you want, you can change your example in a small way to make it work.
Instead of calculating the value of newActionId, you can supply a function to setActionId that takes the previous value as input:
setActionId(oldActionId => oldActionId + 1)
Now, since the previous value is always passed as input, the stale closure does not affect the outcome of the function and your example will work. However, I'm not sure about the design anyway because if a user hits the button again, you will have multiple timeouts running simultaneously which will cause a havoc in the long run. Nonetheless, if you want to make sure it works as expected, you could try it that way. If you could guarantee that the function would only be executed once, by restricting the button to only be pressed once, it would be a better design.
Good luck!

How can we implement componentWillUnmount using react hooks?

The method componentWillUnmount() is invoked immediately before a component is unmounted and destroyed. If we use useEffect with an empty array ([]) as the second argument and put our function in return statement it will be executed after the component is unmounted and even after another component will be mounted. This is done for performance reasons as far as I understand. In order not to delay rendering.
So the question is - how can we call some function using hooks before a component gets unmounted?
What I am trying to do is an application which saves user's input as he types (without submitting form). I use setInterval to save updated text every N seconds. And I need to force save updates before the component will unmount. I don't want to use prompt by react router before navigating. This is an electron application. I appreciate any thoughts or advice on how to implement such functionality.
Update
Unfortunately, Effects with Cleanup run after letting the browser paint. More details can be found here: So What About Cleanup?. It basically means that cleanup is run after a component is unmounted and it is not the same as executing code in componentWillUnmount(). I can clearly see the sequence of calls if I put console.log statements in the cleanup code and in another component. The question is whether we can execute some code before a component is unmounted using hooks.
Update2
As I can see I should better describe my use case. Let's imagine a theoretical app which holds its data in a Redux store. And we have two components with some forms. For simplicity, we don't have any backend or any async logic. We use only Redux store as data storage.
We don't want to update Redux store on every keystroke. So we keep actual values in the local component's state which we initialize with values from the store when a component mounts. We also create an effect which sets up a setInterval for 1s.
We have the following process. A User types something. Updates are stored in the local component state until our setInterval callback is called. The callback just puts data in the store (dispatches action). We put our callback in the useEffect return statement to force save to store when the component gets unmounted because we want to save data to store in this case as soon as possible.
The problem comes when a user types something in the first component and immediately goes to the second component (faster than 1s). Since the cleanup in our first component will be called after re-rendering, our store won't be updated before the second component gets mounted. And because of that, the second component will get outdated values to its local state.
If we put our callback in componentWillUnmount() it will be called before unmounting and the store will be updated before the next component mounts. So can we implement this using hooks?
componentWillUnmount can be simulated by returning a function inside the useEffect hook. The returned function will be called just before every rerendering of the component. Strictly speaking, this is the same thing but you should be able to simulate any behaviour you want using this.
useEffect(() => {
const unsubscribe = api.createSubscription()
return () => unsubscribe()
})
Update
The above will run every time there is a rerender. However, to simulate the behaviour only on mounting and unmounting (i.e. componentDidMount and componentWillUnmount). useEffect takes a second argument which needs to be an empty array.
useEffect(() => {
const unsubscribe = api.createSubscription()
return () => unsubscribe()
}, [])
See a more detailed explanation of the same question here.
Since the introduction of the useLayoutEffect hook, you can now do
useLayoutEffect(() => () => {
// Your code here.
}, [])
to simulate componentWillUnmount. This runs during unmount, but before the element has actually left the page.
The question here is how do you run code with hooks BEFORE unmount? The return function with hooks runs AFTER unmount and whilst that doesn’t make a difference for most use cases, their are some where it is a critical difference.
Having done a bit of investigation on this, I have come to the conclusion that currently hooks simply does not provide a direct alternative to componentWillUnmount. So if you have a use case that needs it, which is mainly for me at least, the integration of non-React libs, you just have to do it the old way and use a component.
Update: see the answer below about UseLayoutEffect() which looks like it may solve this issue.
I agree with Frank, but the code needs to look like this otherwise it will run only on the first render:
useLayoutEffect(() => {
return () => {
// Your code here.
}
}, [])
This is equivalent to ComponentWillUnmount
Similar to #pritam's answer, but with an abstracted code example. The whole idea of useRef is to allow you to keep track of the changes to the callback and not have a stale closure at the time of execution. Hence, the useEffect at the bottom can have an empty dependency array to ensure it only runs when the component unmounts. See the code demo.
Reusable hook:
type Noop = () => void;
const useComponentWillUnmount = (callback: Noop) => {
const mem = useRef<Noop>();
useEffect(() => {
mem.current = callback;
}, [callback]);
useEffect(() => {
return () => {
const func = mem.current as Noop;
func();
};
}, []);
};
After a bit of research, found that - you could still accomplish this. Bit tricky but should work.
You can make use of useRef and store the props to be used within a closure such as render useEffect return callback method
function Home(props) {
const val = React.useRef();
React.useEffect(
() => {
val.current = props;
},
[props]
);
React.useEffect(() => {
return () => {
console.log(props, val.current);
};
}, []);
return <div>Home</div>;
}
DEMO
However a better way is to pass on the second argument to useEffect so that the cleanup and initialisation happens on any change of desired props
React.useEffect(() => {
return () => {
console.log(props.current);
};
}, [props.current]);
I got in a unique situation where the useEffect(() => () => { ... }, []); answers did not work for me. This is because my component never got rendered — I was throwing an exception before I could register the useEffect hook.
function Component() {
useEffect(() => () => { console.log("Cleanup!"); }, []);
if (promise) throw promise;
if (error) throw error;
return <h1>Got value: {value}</h1>;
}
In the above example, by throwing a Promise<T> that tells react to suspend until the promise is resolved. However, once the promise is resolved, an error is thrown. Since the component never gets rendered and goes straight to an ErrorBoundary, the useEffect() hook is never registered!
If you're in a similar situation as myself, this little code may help:
To solve this, I modified my ErrorBoundary code to run a list of teardowns once it was recovered
export default class ErrorBoundary extends Component {
// ...
recover() {
runTeardowns();
// ...
}
// ...
}
Then, I created a useTeardown hook which would add teardowns that needed to be ran, or make use of useEffect if possible. You'll most likely need to modify it if you have nesting of error boundaries, but for my simple usecase, it worked wonderfully.
import React, { useEffect, useMemo } from "react";
const isDebugMode = import.meta.env.NODE_ENV === "development";
const teardowns: (() => void)[] = [];
export function runTeardowns() {
const wiped = teardowns.splice(0, teardowns.length);
for (const teardown of wiped) {
teardown();
}
}
type Teardown = { registered?: boolean; called?: boolean; pushed?: boolean } & (() => unknown);
/**
* Guarantees a function to run on teardown, even when errors occur.
*
* This is necessary because `useEffect` only runs when the component doesn't throw an error.
* If the component throws an error before anything renders, then `useEffect` won't register a
* cleanup handler to run. This hook **guarantees** that a function is called when the component ends.
*
* This works by telling `ErrorBoundary` that we have a function we would like to call on teardown.
* However, if we register a `useEffect` hook, then we don't tell `ErrorBoundary` that.
*/
export default function useTeardown(onTeardown: () => Teardown, deps: React.DependencyList) {
// We have state we need to maintain about our teardown that we need to persist
// to other layers of the application. To do that, we store state on the callback
// itself - but to do that, we need to guarantee that the callback is stable. We
// achieve this by memoizing the teardown function.
const teardown = useMemo(onTeardown, deps);
// Here, we register a `useEffect` hook to run. This will be the "happy path" for
// our teardown function, as if the component renders, we can let React guarantee
// us for the cleanup function to be ran.
useEffect(() => {
// If the effect gets called, that means we can rely on React to run our cleanup
// handler.
teardown.registered = true;
return () => {
if (isDebugMode) {
// We want to ensure that this impossible state is never reached. When the
// `runTeardowns` function is called, it should only be ran for teardowns
// that have not been able to be hook into `useEffect`.
if (teardown.called) throw new Error("teardown already called, but unregistering in useEffect");
}
teardown();
if (isDebugMode) {
// Because `teardown.registered` will already cover the case where the effect
// handler is in charge of running the teardown, this isn't necessary. However,
// this helps us prevent impossible states.
teardown.called = true;
}
};
}, deps);
// Here, we register the "sad path". If there is an exception immediately thrown,
// then the `useEffect` cleanup handler will never be ran.
//
// We rely on the behavior that our custom `ErrorBoundary` component will always
// be rendered in the event of errors. Thus, we expect that component to call
// `runTeardowns` whenever it deems it appropriate to run our teardowns.
// Because `useTeardown` will get called multiple times, we want to ensure we only
// register the teardown once.
if (!teardown.pushed) {
teardown.pushed = true;
teardowns.push(() => {
const useEffectWillCleanUpTeardown = teardown.registered;
if (!useEffectWillCleanUpTeardown) {
if (isDebugMode) {
// If the useEffect handler was already called, there should be no way to
// re-run this teardown. The only way this impossible state can be reached
// is if a teardown is called multiple times, which should not happen during
// normal execution.
const teardownAlreadyCalled = teardown.called;
if (teardownAlreadyCalled) throw new Error("teardown already called yet running it in runTeardowns");
}
teardown();
if (isDebugMode) {
// Notify that this teardown has been called - useful for ensuring that we
// cannot reach any impossible states.
teardown.called = true;
}
}
});
}
}
It does not matter wether the returned function from useEffect gets called before or after the component unmounted: You still have access to the states valuey through the closure:
const [input, setInput] = useState(() => Store.retrieveInput());
useEffect(() => {
return () => Store.storeInput(input); // < you can access "input" here, even if the component unmounted already
}, []);
If you don't manage the input in the components state, your whole structure is broken and should be changed to manage state at the right place. In your case, you should lift the shared input state of the components to the parent.
ReactJS docs on hooks specify this:
Effects may also optionally specify how to “clean up” after them by
returning a function.
So any function you return in your useEffect hook, will be executed when the component unmounts, as well as before re-running the effect due to a subsequent render.

Categories

Resources