reactjs / react.dev

The React documentation website
https://react.dev/
Creative Commons Attribution 4.0 International
11.07k stars 7.55k forks source link

React 18 concurrent instantiation and disposal #6283

Open christianalfoni opened 1 year ago

christianalfoni commented 1 year ago

Hi there!

I have been reflecting on what React 18 concurrent mode means for instantiation and disposal of objects returning state.

So something you could do before React 18 and StrictMode was:

const SomeComponent = () => {
  const ref = useRef()

  if (!ref.current) {
    ref.current = new SomeStateToAccessInComponentRender()
  }

  useEffect(() => {
    return () => ref.current.dispose()
  }, [])
}

But as I understand, with React 18 (spearheaded by StrictMode) you can not really rely on creating instances during "rendering" cause React might call the function body multiple times ,and even abandon it, before mounting and running any effects.

I understand that pure components are necessary for concurrent mode and to me it makes a lot of sense that React is becoming this pure state synchronisation using subscriptions type of implementation. But what does not make sense to me is that there is no way to "create something" when React has the intention of mounting a component and "dispose that something" when React is abandoning the component due to concurrent mode or unmounting it.

I wish there was some explicit documentation/statement that explains you can not rely on React 18 to control the instantiation and disposal of objects and more importantly some guides to how you should actually deal with it.

Which brings me to... have I gotten it wrong? Is there a way to safely do this? And if not, do we now rely on external state stores to determine when to instantiate/dispose of objects?

christianalfoni commented 1 year ago

So I found the following discussion: https://github.com/reactwg/react-18/discussions/18

The discussion gives a lot of insight into useRef and also use of a getter prop. But it is still not obvious to me how you would handle something like this.

As I understand React 18 can call the function bodies of components without applying anything to the DOM (no ref callbacks running) or running effects (no useEffect running). Then decide to just discard that at a later point? So if you need to instantiate something related to rendering components, there does not seem to be any way to dispose of that if React decides to discard the rendering? 🤔

The concrete questions I would love to see answered and documented is:

I saw in an article: "React will create component trees in the background where it can pause, resume and discard that process"

I saw in an article: "First render has high priority"

gaearon commented 1 year ago

Think of rendering as a calculation. The purpose of rendering is calculating a tree. There is no lifecycle associated with that by design. The lifecycle is only for attaching/detaching already calculated trees.

Can you say more about why calculating a tree requires a resource? What is that resource? Concrete examples would help.

christianalfoni commented 1 year ago

Yes, sorry, so for example at CodeSandbox we have a resource (class) called PitcherClient, which is part of an SDK. This resource is responsible for talking to our VM process. We create this resource based on the current sandbox/repo you are consuming in the editor, where the sandbox/repo has a projectId.

The PitcherClient resource is created by a context provider and is exposed to several components which extracts state from this resource to display in the UI, amongst other things. We use the projectId as a ref on this context provider to remount it whenever you change sandbox/repo. We need to dispose of the resource when the component unmounts to disconnect from the current VM.

So simplified it looks something like this:

function PitcherClientProvider({ projectId, children }) {
  const ref = useRef()

  if (!ref.current) {
    ref.current = new PitcherClient(projectId)
  }

  useEffect(() => {
    return () => ref.current.dispose()
  }, [])

  return <PitcherClientContext.Provider value={ref.current}>{children}</PitcherClientContext>
}

function ProjectPage({ projectId }) {
  return (
    <PitcherClientProvider key={projectId} projectId={projectId}>
      <ProjectEditor projectId={projectId} />
    </PitcherClientProvider>
  )
}

We are not attaching anything to the DOM with our context provider, so we can not use the ref callback to determine any attaching/detaching to the DOM.

As you can see we quickly fail in StrictMode here as we are using an effect to dispose of the resource, but it works fine in production... at least for now.

We can jump hoops and create global state to create and dispose of the resource outside of Reacts reconciliation and use that same state to determine when the context provider should be rendered. But the lifecycle of this context provider IS the lifecycle of the resource... having this strong lifecycle coupling between a resource and a component is something I think makes us write more readable and predictable code, and has felt natural in React up to this point.

But this is not really me trying to say that React is doing something wrong, it is just really difficult to infer from the documentation when this code can fail in production. Cause currently it does not fail, it only fails with StrictMode.

In short I lack understanding of how concurrent mode really works and I can not find any solid documentation on it. Just bits and pieces on old talks and articles and a lot is still up for interpretation.