pmndrs / zustand

🐻 Bear necessities for state management in React

Home Page:https://zustand-demo.pmnd.rs/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Zombie child, yet again

ratoi-crysty opened this issue · comments

There still seems to be the issue of zombie child. It requires some special criteria to reproduce:

  1. The state needs to have initial value
  2. The delete action, needs to be async called in regards to the event handler (Ex.: wrap the action call inside a setTimeout)
  3. This is reproducible only on the elements that were present in the initial state.

Note, that the same scenario is not reproducible while using redux with hooks.

Repo to reproduce: https://github.com/ratoi-crysty/zustand-zombie-child

Thanks for the repro.
This seems the same as what we discussed in #286.
The quick fix is: https://codesandbox.io/s/fast-shadow-12726?file=/src/components/example/list.tsx:631-660

Note, that the same scenario is not reproducible while using redux with hooks.

This, I'm not sure why though.

Yup! It seemed related to that and as you pointed out in your codebox, using unstable_batchedUpdates, will do the trick.

But I still have one question though.

What would be the downsides of moving the subscription from the useEffect to directly in the hook call? (Ex.: index.tsx)
This way, the listeners will be added in the same order as the component tree render order, therefore, avoiding having the child listener called before the parent. In this case, it should dramatically reduce, if not eliminate, the problem with the zombie child, as the parent will be rendered before the child.

As for the scenario when using react-redux. It seems that they are already using it. In the subscription file (Subscription.js) they are using batch to wrap the all the listeners calls. By default, the the batch function is unstable_batchedUpdates. This is set in index.js

What would be the downsides of moving the subscription from the useEffect to directly in the hook call?

That would be a side effect in render, which is never recommended in react.
In legacy mode, it would work, but in concurrent mode, it will be really problematic.
It may subscribe to the store and never unsubscribe from it, in some scenarios.

Thanks for your answer. It makes sense.

As for the scenario when using react-redux. It seems that they are already using it. In the subscription file (Subscription.js) they are using batch to wrap the all the listeners calls. By default, the the batch function is unstable_batchedUpdates. This is set in index.js

Maybe we should add the functionality to allow passing a batching method when creating a store, this way, it should prevent the zombie-child issue and also improve performance as it will call all listeners of that store inside one batch (if that is done outside the react event, like setTimeout). And having it optional, it will not add any breaking changes.

What's your opinion on this?

As for the scenario when using react-redux. It seems that they are already using it. In the subscription file (Subscription.js) they are using batch to wrap the all the listeners calls. By default, the the batch function is unstable_batchedUpdates. This is set in index.js

Ah, nice finding. I thought I saw this before, but I was searching batchedUpdates today and nothing found. 😓

We could try using batchedUpdates, but it'd be not easy to make bundles for react-dom and react-native. I wonder if v4 #160 will solve this issue eventually...

Oh, setBatch is totally opt-in. I missed it. That might work. Would you like to work on it?

But, then, the DX is not very good, isn't it? The workaround in #286 seems easier to me.

After giving further thoughts, I think that having a setBatch will probably not add allot of value. Because, if you manually wrap your action (in my example, deleteItem) inside the batching method, that will automatically wrap all the listeners calls inside it, as zustand will do this synchronously. And therefore, have the same value as a setBatch method. Another way to it, is to manually wrap the set method in the store creator and only use the wrapped method to update the state, this will also have the same effect as setBatch.

What do you think? Do you see any value in implementing a setBatch? Probably something that I have missed?
If not, then you could close the issue, as it works as expected.

Yeah, that's what I meant by "DX is not very good". I don't think it's worth the effort, at this point. But let us keep this in mind.

For now, it would be best for us to add a small section about the workaround in readme.md. Would you or anyone like to work on it? cc @martonlanga @Tirzono .

(I will close this issue a bit later.)

@dai-shi let me know if this is ok or if I should change anything.

commented

one question @dai-shi didn't @JeremyRH find a way to do this without batchedupdates? i remember he told me we can now remove it. the trouble is mostly that we're using two reconcilers: react-dom and r3f.

our middleware currently looks like this:

import { Renderer } from 'react-three-fiber'
import { unstable_batchedUpdates } from 'react-dom'

const batched = (config: StateCreator<BuerliState>) => (
  set: SetState<BuerliState>,
  get: GetState<BuerliState>,
  api: any,
) => config(args => unstable_batchedUpdates(() => Renderer.batchedUpdates(() => set(args))), get, api)

if there is any way to avoid this we would be very happy ...

btw react automatically batches in blocking and concurrent mode, has anyone tested if the problem persists?

To fix this in non-concurrent mode without batchedupdates, you need to ensure a parent's setState is called before its children's setState. There are many ways to do that. I did it by keeping track of render order (mutating an array during render) and it didn't work in concurrent mode.
Someone should be able to figure out a solution but it might involve doing different things in concurrent mode.

if there is any way to avoid this we would be very happy ...

well, this looks to me the easiest and the cleanest.

I'm interested if #160 fixes this issue from the ground. Otherwise, the current solution would be good in total.

I did it by keeping track of render order (mutating an array during render)

My assumption is this doesn't work very well performance-wise even in legacy mode.

commented

just switching to blocking would also take care of it right? that would be a good solution if it did.

AFAIU, you would need batchedUpdates in non-react callbacks, if you needed to ensure top-down updates, even in legacy mode.
I expect the future version of react may fix this and that's why it's unstable_.

commented

it says here that both blocking and cm autobatch https://reactjs.org/docs/concurrent-mode-adoption.html#feature-comparison

*: Legacy mode has automatic batching in React-managed events but it’s limited to one browser task. Non-React events must opt-in using unstable_batchedUpdates. In Blocking Mode and Concurrent Mode, all setStates are batched by default.

Oh, you mean Blocking Mode which is the one between CM and LM. I missed that part of your comment.

In Blocking Mode and Concurrent Mode, all setStates are batched by default.

This, I'm not sure. Hopefully, they batch multiple setStates called in async but in a short period?

btw react automatically batches in blocking and concurrent mode, has anyone tested if the problem persists?

Last I tested, using ReactDom.unstable_batchedUpdates prevented the zombie child issue. This should mean the problem wont persist in blocking or concurrent mode.

Should this issue be reopened?
Perhaps added as a middleware to modify set?

I've been trying to figure out where the unstable_batchedUpdates api is actually required. As far as I've understood, it's only required when using setState. Not sure why it's not on by default.

More reading:
facebook/react#18402

I feel like this is not a real issue from the library perspective. React may behave differently in the future as we discussed.
The case we need unstable_batchedUpdates is rare as I understand, and adding it to setState by default doesn't sound correct to me, but not very sure. AFAIR, react-redux had it by default, but it's now opt-in.

A new middleware to modify set might be an option if there's a certain demand.

btw, react doesn't seem to have Blocking Mode any longer.

Could someone help me understand the following. In the readme, there is this example:

image

What I can't understand is why unstable_batchedUpdates() is used in location A and not in location B?

Consider the following example:

image

Here, the callback is a function that the api wrapper will call when an ajax request is complete. Yet it is declared in location B, not somewhere outside.

In my particular example, what part exactly should I wrap with unstable_batchedUpdates()? -- many thanks

The readme doesn't recommend having unstable_batchedUpdates() in "B", because increaseFishes can be called from react callbacks. You should use it where you are sure that it's non-react callback.

In your case, if onSuccess is non-react callback and if you are facing the zombine problem, you can surely put it there.

Thanks. How do I distinguish a non-react callback from a react-callback? Not sure I have a clear understanding of this term's meaning.

react callbacks are like <button onClick={...}>. non-react callback is like setTimeout(...).
react 18 will auto batch for non-react callbacks. actually, react 18 alpha discussion would explain the difference better. reactwg/react-18#21

Almost clear! The last bit that's confusing is this:
Would a Promise's than() callback be still classified a non-react callback even if the Promise is created inside the onClick() react callback?

I suppose so based on their example, could you confirm please?

function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

... and why I keep asking for clarifications here is that I eventually would like to understand what positions exactly it is safe to call the bath wrapper function considering the is zustand intermediary. In other words, does the fact that there is zustand in between adds any limitations as to when I can / cannot call unstable_batchedUpdates()?

Would a Promise's then() callback be still classified a non-react callback even if the Promise is created inside the onClick() react callback?

yes.

Yup! It seemed related to that and as you pointed out in your codebox, using unstable_batchedUpdates, will do the trick.

But I still have one question though.

What would be the downsides of moving the subscription from the useEffect to directly in the hook call? (Ex.: index.tsx) This way, the listeners will be added in the same order as the component tree render order, therefore, avoiding having the child listener called before the parent. In this case, it should dramatically reduce, if not eliminate, the problem with the zombie child, as the parent will be rendered before the child.

your link address "index.tsx" has been 404,