react-native-navigation icon indicating copy to clipboard operation
react-native-navigation copied to clipboard

Updated state not available in navigationButtonPressed method in a functional component

Open varungupta85 opened this issue 4 years ago • 3 comments

🐛 Bug Report

I am running into a strange problem and it may be related to how Javascript works than an issue in react-native-navigation. I created a functional component which also listens to navigation button pressed. So, I followed the recipe on the documentation website and wrote a component that looks like below:

const AutoSnoozeConfigurationContainer = props => {
  const colorScheme = useSelector(state => state.userSettings.colorScheme)

  const [enabled, setEnabled] = useState(props.enabled)
  const [duration, setDuration] = useState(props.duration)
  const [count, setCount] = useState(props.count)

  useEffect(() => {
    const listener = {
      navigationButtonPressed: ({ buttonId }) => {
        if (buttonId === 'cancel') {
          Navigation.pop(props.componentId)
        }
        if (buttonId === 'save') {
          console.log('onSave', enabled, duration, count)
          props.onSave({ enabled, duration, count })
          Navigation.pop(props.componentId)
        }
      }
    }
    // Register the listener to all events related to our component
    const unsubscribe = Navigation.events().registerComponentListener(
      listener,
      props.componentId
    )
    return () => {
      // Make sure to unregister the listener during cleanup
      unsubscribe.remove()
    }
  }, [])

  const autoSnoozeDurations = Constants.AutoSnoozeDurations.map(item => ({
    ...item,
    selected: item.value === duration
  }))

  const autoSnoozeCounts = Constants.AutoSnoozeCounts.map(item => ({
    ...item,
    selected: item.value === count
  }))

  console.log('render', enabled, duration, count)

  return (
    <ScrollView style={styles.sceneContainer}>
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          alignItems: 'center'
        }}>
        <Text colorScheme={colorScheme} style={styles.settingLabelStyle}>
          {props.isGlobal ? I18n.t('autoSnooze') : I18n.t('autoSnoozePerAlarm')}
        </Text>
        <Switch
          value={enabled}
          onValueChange={setEnabled}
          style={{ marginHorizontal: 10 }}
        />
      </View>

      <SeparatorWithTitle
        title={I18n.t('duration')}
        style={{ textAlign: 'center' }}
      />
      <ComponentListView
        component={AutoSnoozeSetting}
        data={autoSnoozeDurations}
        otherProps={{
          onSelect: setDuration,
          autoSnoozeEnabled: enabled
        }}
        renderSeparator
        separatorStyle={{ marginLeft: 10 }}
      />
      <SeparatorWithTitle
        title={I18n.t('repeat')}
        style={{ textAlign: 'center' }}
      />
      <ComponentListView
        component={AutoSnoozeSetting}
        data={autoSnoozeCounts}
        otherProps={{
          onSelect: setCount,
          autoSnoozeEnabled: enabled
        }}
        renderSeparator
        separatorStyle={{ marginLeft: 10 }}
      />
    </ScrollView>
  )
}

When I hit Save on this component, the onSave method is called with the old state. Even though, the component renders the updated value of duration and count when changed on the screen. But when I Save, the onSave method is called with the initial values of enabled, duration, and count.

I changed it to a class component and it works fine. There is no other change than changing it to a class component that looks like below:

class AutoSnoozeConfigurationContainer extends Component {
  constructor(props) {
    super(props)
    Navigation.events().bindComponent(this)
  }

  navigationButtonPressed = ({ buttonId }) => {
    if (buttonId === 'cancel') {
      Navigation.pop(this.props.componentId)
    }
    if (buttonId === 'save') {
      console.tron.log('onSave', this.state)
      this.props.onSave(this.state)
      Navigation.pop(this.props.componentId)
    }
  }

  state = {
    enabled: this.props.enabled,
    duration: this.props.duration,
    count: this.props.count
  }

  setEnabled = enabled =>
    this.setState({
      enabled: enabled
    })

  setDuration = duration =>
    this.setState({
      duration: duration
    })

  setCount = count =>
    this.setState({
      count: count
    })

  render() {
    const autoSnoozeDurations = Constants.AutoSnoozeDurations.map(item => ({
      ...item,
      selected: item.value === this.state.duration
    }))

    const autoSnoozeCounts = Constants.AutoSnoozeCounts.map(item => ({
      ...item,
      selected: item.value === this.state.count
    }))

    console.log('render', this.state)

    return (
      <ScrollView style={styles.sceneContainer}>
        <View
          style={{
            flexDirection: 'row',
            justifyContent: 'space-between',
            alignItems: 'center'
          }}>
          <Text
            colorScheme={this.props.colorScheme}
            style={styles.settingLabelStyle}>
            {this.props.isGlobal
              ? I18n.t('autoSnooze')
              : I18n.t('autoSnoozePerAlarm')}
          </Text>
          <Switch
            value={this.state.enabled}
            onValueChange={this.setEnabled}
            style={{ marginHorizontal: 10 }}
          />
        </View>

        <SeparatorWithTitle
          title={I18n.t('duration')}
          style={{ textAlign: 'center' }}
        />
        <ComponentListView
          component={AutoSnoozeSetting}
          data={autoSnoozeDurations}
          otherProps={{
            onSelect: this.setDuration,
            autoSnoozeEnabled: this.state.enabled
          }}
          renderSeparator
          separatorStyle={{ marginLeft: 10 }}
        />
        <SeparatorWithTitle
          title={I18n.t('repeat')}
          style={{ textAlign: 'center' }}
        />
        <ComponentListView
          component={AutoSnoozeSetting}
          data={autoSnoozeCounts}
          otherProps={{
            onSelect: this.setCount,
            autoSnoozeEnabled: this.state.enabled
          }}
          renderSeparator
          separatorStyle={{ marginLeft: 10 }}
        />
      </ScrollView>
    )
  }
}

Here the updated state is passed to onSave method. I thought it may have something to do with Javascript closures and I tried a couple of things like moving onSave call out of the listener and use useCallback hook etc. without any luck.

I am not sure if this is a bug or not and if I have a mistake in the code, I would appreciate if somebody can explain the mistake.

Have you read the Contributing Guidelines on issues?

Yes

To Reproduce

Create a screen that is a functional component that updates the state somehow and have a button that invokes a callback with the state values.

Expected behavior

The callback should be called with updated state values.

Actual Behavior

The callback was called with initial state values.

Your Environment

  • React Native Navigation version: 6.12.2
  • React Native version: 0.64.0
  • Platform(s) (iOS, Android, or both?): Both
  • Device info (Simulator/Device? OS version? Debug/Release?): All

Reproducible Demo

(Paste the link to an example repo and exact instructions to reproduce the issue.)

varungupta85 avatar May 27 '21 19:05 varungupta85

This is related to your code. Check these docs: https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect.

You need to add enabled, duration, count to your useEffect deps.

useEffect(() => {}, [enabled, duration, count]). So the effect will be triggered with the new values when the dependencies change.

mateioprea avatar May 27 '21 20:05 mateioprea

I did try that but that doesn't fix it either. Also, I don't want to rerun the effect meaning attach the event listener again when the value of these states change. It should be the latest value when the onSave button is pressed. I am only attaching the event handler in the useEffect.

varungupta85 avatar May 28 '21 04:05 varungupta85

I don't want to rerun the effect meaning attach the event listener again when the value of these states change

That's how useEffect works. You'll get stale value if you don't use the correct dependency array.

If you don't want to resubscribe (why?), you can store your value in a ref which doesn't need to be in dependency array, but that's working around how React works.

satya164 avatar Apr 03 '22 19:04 satya164

Seeing the same problem but with useCallback. Call a function from a rightButton -> state not updated

Miyaguisan avatar Nov 28 '22 16:11 Miyaguisan