Skip to content

Bug: Strange component re-render that breaks component state #36372

@sgrishchenko

Description

@sgrishchenko

React version: 19.2.5

Steps To Reproduce

  1. Create React app using create-vite
  2. Remove StrictMode from main.tsx (just to reduce the number of debug logs)
  3. Replace code in App.tsx
  4. Run npm dev
  5. Open browser
  6. Click the "Close" button
  7. Click the "Open" button

Code example:

import {type ReactNode, useState, useEffect} from 'react'
import './App.css'

function Internal(props: { opened: boolean, children: ReactNode }) {
  const {opened: externalOpened} = props

  const [opened, setOpened] = useState(externalOpened)
  const [prevOpened, setPrevOpened] = useState(externalOpened)

  console.log('render', { opened, externalOpened })

  if (externalOpened !== prevOpened) {
    setPrevOpened(externalOpened)

    console.log('externalOpened !== prevOpened', externalOpened)
    setOpened(externalOpened)
  }

  useEffect(() => {
    const timeout = setTimeout(() => {
      if (!externalOpened) {
        console.log('useEffect', false)
        setOpened(false)
      }
    }, 300)

    return () => clearTimeout(timeout);
  }, [externalOpened])

  return opened ? <>{props.children}</> : null
}

function App() {
  const [opened, setOpened] = useState(true)

  return (
      <>
      <button onClick={() => {
        console.log('open')
        setOpened(true)
      }}>Open</button>
      <Internal opened={opened}>
        <button onClick={() => {
          console.log('close')
          setOpened(false)
        }}>Close</button>
      </Internal></>
  )
}

export default App

The current behavior

When you click on the "Open" button after closing, the panel from Internal component is shown and is hidden immediately after showing.

Logs in the browser console:

App.tsx:10 render {opened: true, externalOpened: true}
15:41:27.506 "---"
15:41:27.509 '---'
15:41:30.045 App.tsx:44 close
15:41:30.047 App.tsx:10 render {opened: true, externalOpened: false}
15:41:30.047 App.tsx:15 externalOpened !== prevOpened false
15:41:30.047 App.tsx:10 render {opened: false, externalOpened: false}
15:41:30.350 App.tsx:22 useEffect false
15:41:32.597 "---"
15:41:32.604 '---'
15:41:34.350 App.tsx:39 open
15:41:34.352 App.tsx:10 render {opened: false, externalOpened: true}
15:41:34.352 App.tsx:15 externalOpened !== prevOpened true
15:41:34.352 App.tsx:10 render {opened: true, externalOpened: true}
15:41:34.355 App.tsx:10 render {opened: false, externalOpened: true}

The expected behavior

When you click on the "Open" button after closing, the panel from Internal component should be just shown.

Notes

The main question for me is why the last render happens and why the some outdated state is used? I provided timestamps to see that the last render can't be triggered by planed setTimeout or something like this. I tried to investigate the problem. I found some information about such a thing as state bailing out, but all issues I found are closed with a note that bailing out should not affect render logic or state manipulations. What I observe from the logs:

  • When I press the "Close" button, the effect is triggered. However, at the moment when timeout is executed, both App and Internal components are in closed state, so as far as I understand, React bails out update from useTimeout, looks reasonable, no questions here.
  • Then, when I press the "Open" button, for some reason I see the last render. It seems like a reaction to bail out setState from previously triggered setTimout, so finally Internal component is rendered in closed state even though App component in open state. If I am removing useEffect block the problem disappears.

Possible questions

  • "Why is the provided code so complicated and strange?" - It minimal reproducible example where a problem appears. Yes, it is abstract, but I faced a real problem in production code, where there was an attempt to sync internal and external states and perform some synchronizations on animation state (setTimiout is some emulation of transitionend event)
  • "Why useEffect hook is needed here?" - Again, it is examples, real code is more complex and useEffect it is emulation of synchronizing component state with animation events.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions