-
-
Notifications
You must be signed in to change notification settings - Fork 90
[On hold] React Concurrent mode: avoid side effects in render #53
Comments
@SomeHats I think you bring up good points and I'm not sure how all this works under the hood. I ran into an issue where I was using a hook that threw for suspense and needed a workaround, so I came up with the following for now. I was thinking by the time all of this stuff stabilizes I will probably be able to remove it, but here it is in case it helps or anyone has objections/suggestions:
I use it pretty much all over the place like this, as opposed to using the
I'm not sure if this is better/worse performance-wise than the observer HOC, but I like the code style this way better than a HOC. |
Yep- that’s actually what led me to notice the memory leak/side effect
issue in the first place! I wound up doing something similar in my own
prototype. It seems like a reasonable enough fix for the thrown issue, but
the side effects thing is I think a lot harder to solve.
There shouldn’t be much difference between useObserver and the observer HOC
- more or less all the HOC does is wrap your entire component in
useObserver anyway:
https://github.com/mobxjs/mobx-react-lite/blob/d46cea3a7f812f75ec5582237806b084f0b70828/src/observer.ts#L38
…On Fri, 1 Feb 2019 at 21:08, Joe Noon ***@***.***> wrote:
@SomeHats <https://github.com/SomeHats> I think you bring up good points
and I'm not sure how all this works under the hood. I ran into an issue
where I was using a hook that threw for suspense and needed a workaround,
so I came up with the following for now. I was thinking by the time all of
this stuff stabilizes I will probably be able to remove it, but here it is
in case it helps or anyone has objections/suggestions:
import {useObserver} from 'mobx-react-lite';
export function useCustomObserver<T>(fn: () => T, baseComponentName?: string): T {
let err;
let res: any;
useObserver<any>(() => {
try {
res = fn();
} catch (e) {
err = e;
}
}, baseComponentName);
if (err) {
throw err;
}
return res;
}
I use it pretty much all over the place like this, as opposed to using the
observer HOC:
export const FooComponent: React.SFC<{}> = () =>
useCustomObserver(() => {
// ... other hooks, rendering
});
I'm not sure if this is better/worse performance-wise than the observer
HOC, but I like the code style this way better than a HOC.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#53 (comment)>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/ABa6cOBziWtxdNB1FJeFbst2ZPPr6Qdlks5vJR1JgaJpZM4afN4n>
.
|
Thank you for opening this discussion.
Suspense for data fetching is far from official yet. Throwing a promise is most likely going to happen, but we don't know certainly that it will cause discard of the hooks or what else is going to happen. The code for that is not in React yet. Either way, I think the PR #51 (just merged) has fixed this concern of recreating reactions on every render. I would gladly accept a PR that handles a thrown promise within a reaction. Not sure what should be a correct behavior at this point. Perhaps it just shouldn't be supported at all. If someone wants to throw a promise, the |
I did encounter these issues working with Suspense, but it's fairly easy to demonstrate issues even without Suspense on 16.8.0: https://codesandbox.io/s/18lo9r7kp3 If you run this and take a look at the console, you'll see how:
I mention concurrent mode in the title of this issue, but that's not the root of the problem here - but it will as far as I can tell make these issues come up a lot more frequently. In general, the advice from the React team has been to avoid side effects in render since March 2018. |
create-subscription is the React team's suggested pattern for solving this. Applying that same pattern here would look something like: const useObserver = (fn) => {
const [cachedResult, setCachedResult] = useState(null);
useEffect(() => {
return mobx.reaction(
() => fn(),
result => setCachedResult(result),
{fireImmediately: true},
);
}, [fn]);
if (cachedResult) {
return cachedResult;
} else {
return fn();
}
} This way the reaction isn't created until the component is mounted, but as that might be after an async gap where data it depends on may have changed, we need to trigger a re-render on mount which obviously is not ideal. edit: the above doesn't actually work for a bunch of reasons but something along those lines (deferring creating the reaction until the component is mounted) is an approach that would work |
My point is that Suspense for data fetching is planned for Q4 of this year. Whatever the current behavior is, it will most likely change, so I wouldn't be really building anything on top of that. It's just way too experimental. I've just released version 0.3.7. Can you please check if it behaves better? Your suggestion cannot work I think. There is an important reason to have the Reaction available on the first render otherwise anything rendered won't be tracked for observables. And running renders again in |
This is not in any alpha, but function components that invoke any hook at all will always be double-rendered in In particular, all state created with hooks will be thrown out on the first pass, so all This simulates well how a pre-empted render behaves in |
Also, a (caveat: this is just how it's implemented right now, there's no guarantee this is how the "final" |
That can be fixed quite easily, will create a PR.
This is correct, the reaction needs to be setup (either in componentWillMount, or at beginning of rendering), or otherwise MobX won't be able to track what is happening during render.
|
So far biggest risk seem:
First dirty work around that pops to my mind, keep track of whether a useEffect was actually called, and if not, dispose the reaction after a set timeout (e.g. 1000 ms?) |
Try testing against a build from master, or waiting for 16.8.0. It should be safe to just call setState in a useLayoutEffect -- the component will only rerender if the value actually changed even without React.memo, and it will be synchronous even in ConcurrentMode so users won't see the outdated data on screen. The "double render" will be necessary for correctness, unless you're going to do a garbage collection hack as you mentioned. If you are, you probably should give a longer timeout, like 6s (the default deadline before an asynchronous update becomes synchronous is 5s; but this is an implementation detail). |
Thanks you, @Jessidhia & @mweststrate. One way we could avoid the timeout hack would be if MobX had some way of telling if a value had gone out of date without creating a reaction. You could imagine some API that gets passed an expression, and returns the value of that expression and some token that represents everything that would have been tracked in order to create that value. Later, in useLayoutEffect, you could set up your reaction but also use that token to check whether a re-render is needed. I'm not hugely familiar with MobX internals but from stepping through the source a few times it seems this would be pretty hard to do right now. Maybe still worth throwing out there though! |
I think it might be possible (read the values, subscribe later) by introducing an abstraction for that in the I think there are two possible solutions for this:
Would anybody that has little experience with this, willing to set up a PR with tests that check the concurrent render behavior, with render trees that will be discarded etc, so that we can verify the impact of either solutions? |
Wouldn't cleaning up the reactive wiring in caught errors solve this issue for suspended renders and user errors at least? I mean calling an onUnmount here. This way it would clean up on real errors (not much else that can be done anyway) and on suspends (react will re-render the components after the thrown Promise is resolved and mobx can reobserve it). It wouldn't solve uncommited renders without thrown errors though (which may happen with async React, I have no idea.) |
If there is a suspend on your boundary, whatever you rendered so far will be thrown out anyway. I think there might be some degree of overthinking here. It's also not possible to catch suspends. |
Note to self: should investigate if mobx-react suffers from the same issue |
I think at this point we can put this easy on hold; we know how we could eliminate the issue, but since concurrent mode is so alpha, I am a bit hesitant to fix an issue that might be potentially addressed by React itself. (Assuming no one is using concurrent mode in production atm?) |
Also interesting approach, where the rendering is stored in state: https://medium.com/@suhdev/mobx-quick-way-to-converting-class-based-components-to-use-react-hooks-c54f8b67755d. Since the initial rendering doesn't subscribe, there will be a double rendering (could maybe be skipped with a flag), but no reaction leak |
It may be worth considering fixing this up even though concurrent mode isn't 'prime time' yet: React We recently turned on React Because one of the things that The result, unfortunately, is that our console contains a lot of warnings caused by |
@RoystonS If you look at the mobx-react-lite/src/useObserver.ts Line 28 in ed1634d
Does it mean that |
I believe that's the case, yes; the I'm just attempting to put together a test case in a PR (as requested by @mweststrate earlier: #53 (comment)) that should demonstrate the problem. |
I find it hard to believe that initializing value is considered a side effect. The problem I see here is that we will be deliberately rendering component without observable logic because Reaction won't be created at first. And then Meh...if Concurrent mode is about such deliberate awkwardness, I think I am going to avoid it for now in hoping they improve it in some way or provide better tools. |
Yep, even Sadly, StrictMode is useful for things other than preparing for concurrent-mode, and it's not possible to turn on parts of StrictMode. :( I'll get this test together to demonstrate the problem. |
Thinking about the two options mentioned in #53 (comment): Outside of strict mode (i.e. in concurrent mode) multiple render phases on a single component (causing leaked Reactions right now) ought to be 'possible but rare', so optimising for that case by introducing something that always caused every Option 1 (cleaning up 'unconfirmed' reactions) might be preferable; one little tweak would be needed to avoid the StrictMode warnings: forceUpdate would need to check that the component got committed before attempting to trigger the update via a state set, in case observable changes are made before the cleanup occurred. e.g. via something like this (which could be made more efficient by being combined into other existing hook calls) in
|
Is the following viable? function useObservable(cb,deps = []) {
if (SSR) return cb();
// It will run twice, but the first run is nearly a no-op as we don't render anything
const [element, setElement] = useState(null);
// LayoutEffect so there is no chance to repaint
useLayoutEffect(() => {
return reaction(cb, setElement, { fireImmediately: true });
}, deps);
// The initial `null` won't be seen by user
return element;
} |
@FredyC well the idea was to not to create "lingering" reactions, without the need to run rendering logic twice... |
But we are not rendering twice anything. The lingering reactions will be cleanup up later. I wouldn't really rely on |
You're not, but there was mentioned
It doesn't prevent double render (it will double render as mentioned in comment). |
Sorry, I don't see any real benefit to your solution compare to the one we made. Except for those lingering reaction, but it's no big deal to clear them up later. |
That |
If I count properly you get 2N component updates/renders, which is the same as rendering whole tree twice. EDIT: Created a sandbox to check it out: https://codesandbox.io/s/l2k7opn649 |
@urugator Correct me if I am wrong, but you essentially want to prevent updating a DOM in a situation when rendered tree changes between initial render and commit phase. How often is that going to happen? In theory, it could happen for animated components which are re-rendering fairly quickly, but I kinda doubt that anyone would use MobX for that anyway :) |
Always, because the proposed solution returns |
I see. Would it hurt that much to actually render what is supposed to without reaction for the first time? If there wouldn't be any change between that and commit, no DOM needs to be updated and it would be only a matter of double call of a render. Only...Well, if we really do that, I can already predict a bunch of people coming here and complaining that their components are rendered twice. It might be a nasty show stopper for some folks obsessed with performance (some might have a good reason for it). As @RoystonS mentioned in #121 (comment) there is a chance that our solution from #119 can potentially cause loss of an update. Well, there is a tradeoff in every solution apparently :/ |
Give me a little bit to write some more unit tests, and then it'll be easier to discuss implementations. 2N renders isn't the same as rendering the tree twice. |
Idea (a bit convoluted though): |
You'd need to know if the values of those observables changed between the first render and the effect (so you can trigger an immediate rerender [this is the same issue I flagged up with #119]). If you only knew the dependencies, that would be hard to do. That's why creating the reaction once and leaving it established is a good thing. |
Released |
Hi! I've been really enjoying mobx-react-lite, thank you so much for this library. I've recently been doing some investigation about using MobX with suspense in react and have noticed some issues with the current useObserver implementation.
First of - components can and will throw. Suspense relies on throwing a promise. I'm a little unsure how MobX handles reactions throwing in general, but right now if you have an observed component and it throws, that upsets MobX and your thrown exception can't be handled in the usual way React does that - with error boundaries and suspense boundaries. That's a relatively simple issue to solve though.
The more serious issue is that once a component throws, React will often completely discard the tree up to the nearest boundary. That means all hooks get rolled back - so you can't rely on each render resulting in a subsequent commit (where useEffect hooks are called and their cleanup functions registered). In general, with the upcoming Concurrent mode, render will often get called more than once.
This can be demonstrated fairly easily with a component like this:
CodeSandbox link
This component logs the following to the console:
So, what does this mean for mobx-react-lite? Right now,
useObserver
works by creating a reaction in the render phase, then cleaning up when the component unmounts:mobx-react-lite/src/useObserver.ts
Lines 24 to 32 in d46cea3
But each
render
call isn't guaranteed to come with a subsequent mount & render. The moment an observed component throws, all the effects of hooks (including theuseRef
for keeping track of the reaction) get discarded, so we end up leaking reactions all over the place.I wonder if there's maybe some sort of thing we can do to defer calling
.track
until auseEffect
callback, but the problem there is there can be an async gap between render and commit - whenuseEffect
gets called. What happens when the derived state changes in that time?Anyway. Thanks again for this project, and for reading all the way through this issue. Super happy to help try and resolve this and submit PRs, but right now it's not clear to me that there's an easy solution. Concurrent mode seems like the future of React though, so I think it's important to figure something out!
The text was updated successfully, but these errors were encountered: