microsoft / ReSub

A library for writing React components that automatically manage subscriptions to data sources simply by accessing them

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

ReSub v2 setState not working?

mikehardy opened this issue · comments

Hey you all - I'm testing out the 2.0.0-rc.1 and vs 1.2.2 I have a component that fails to setState correctly.

  private _onNextPage = () => {
    console.log(
      'TIPI::_onNextPage - was on page ' + this.state.pageIndex + ' (max is ' + this.MAX_PAGE + ')'
    );
   let nextIndex = this.state.pageIndex + 1;
    this.setState({ pageIndex: nextIndex >= this.MAX_PAGE ? this.MAX_PAGE : nextIndex });
    console.log('TIPI::_onNextPage - will be on page ' + this.state.pageIndex);
  };

It's a simple pager, and I get 'will be on page 0' (the initial state) every click with 2.0.0-rc.1 but it increments nicely with 1.2.2.

That's a vague report in and of itself but it is a clear regression with this code between those two versions.

I'm trying to get a repro together but in the meantime I also see this on an npm test execution

mike@isabela:~/work/react-random/resub (master) % npm test

> resub@2.0.0-rc.1 test /home/mike/work/react-random/resub
> run-s clean:* karma:single-run


> resub@2.0.0-rc.1 clean:dist /home/mike/work/react-random/resub
> rimraf dist*


> resub@2.0.0-rc.1 karma:single-run /home/mike/work/react-random/resub
> karma start --singleRun

Starting type checking service...
Using 1 worker with 2048MB memory limit
Type checking in progress...
ℹ 「wdm」: Compiled successfully.
ℹ 「wdm」: Compiling...
ERROR in /home/mike/work/react-random/resub/test/AutoSubscribe.spec.tsx(128,7):
TS2417: Class static side 'typeof OverriddenComponent' incorrectly extends base class static side 'typeof SimpleComponent'.
  Types of property 'getDerivedStateFromProps' are incompatible.
    Type 'GetDerivedStateFromProps<SimpleProps, SimpleState>' is not assignable to type 'GetDerivedStateFromProps<unknown, unknown>'.
      Types of parameters 'nextProps' and 'nextProps' are incompatible.
        Property 'ids' is missing in type 'Readonly<unknown>' but required in type 'Readonly<SimpleProps>'.
Version: typescript 3.5.3
Time: 2315ms
ℹ 「wdm」: Compiled successfully.

Any chance that pageIndex is set inside _buildState?

Just whipped up an example with 2.0.0-rc.1 and things work as expected - here's a snippet
If you could provide any more context, that would be helpful to investigate
image

Here's a reproduction - I didn't mean to leave it vague but you are faster to respond than I can code tiny repro apps :-)

Do this:

npx react-native init ReSubStateTest
cd ReSubStateTest
npm add resub@2.0.0-rc.1

Replace your entire App.js with this:

import React from 'react';
import { View, Button, Text } from 'react-native';
import { ComponentBase } from 'resub';

interface CounterProps {}

interface CounterState {
  counter: number;
}

class Counter extends ComponentBase<CounterProps, CounterState> {

  _buildState(props, state) {
    if (!this.state || this.state.counter == undefined) {
      console.log('_buildState - no initial state, initializing to 0');
      return { counter: 0 };
    } else {
      console.log('_buildState - counter already set to ' + this.state.counter);
      return { counter: this.state.counter };
    }
  }

  _handleClick = () => {
    console.log('_handleClick - incrementing from ' + this.state.counter + ' to ' + (this.state.counter + 1));
    this.setState({ counter: this.state.counter + 1 });
    console.log('_handleClick - counter is now ' + this.state.counter);
  }

  render() {
    return (
      <View>
        <Text>Counter is { this.state.counter }</Text>
        <Button title="Increment" onPress={this._handleClick}/>
      </View>
    );
  }
}

const App: () => React$Node = () => {
  return (
    <>
      <Counter/>
    </>
  );
};

export default App;

Run it? (start your android emulator and npm start + npm run android or the equivalent for ios)

I get this:

 LOG  _buildState - no initial state, initializing to 0
 LOG  _handleClick - incrementing from 0 to 1
 LOG  _handleClick - counter is now 0
 LOG  _buildState - counter already set to 0
 LOG  _handleClick - incrementing from 0 to 1
 LOG  _handleClick - counter is now 0
 LOG  _buildState - counter already set to 0
 LOG  _handleClick - incrementing from 0 to 1
 LOG  _handleClick - counter is now 0
 LOG  _buildState - counter already set to 0
 LOG  _handleClick - incrementing from 0 to 1
 LOG  _handleClick - counter is now 0
 LOG  _buildState - counter already set to 0

🤔 I am usually doing something wrong. But I can't see it. And it works in this style with resub@1.2.2

 LOG  _buildState - no initial state, initializing to 0
 WARN  Warning: componentWillMount has been renamed, and is not recommended for use. See https://fb.me/react-async-component-lifecycle-hooks for details.

* Move code with side effects to componentDidMount, and set initial state in the constructor.
* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.

Please update the following components: Counter
 WARN  Warning: componentWillReceiveProps has been renamed, and is not recommended for use. See https://fb.me/react-async-component-lifecycle-hooks for details.

* Move data fetching code or side effects to componentDidUpdate.
* If you're updating state whenever props change, refactor your code to use memoization techniques or move it to static getDerivedStateFromProps. Learn more at: https://fb.me/react-derived-state
* Rename componentWillReceiveProps to UNSAFE_componentWillReceiveProps to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.

Please update the following components: Counter
 WARN  Warning: componentWillUpdate has been renamed, and is not recommended for use. See https://fb.me/react-async-component-lifecycle-hooks for details.

* Move data fetching code or side effects to componentDidUpdate.
* Rename componentWillUpdate to UNSAFE_componentWillUpdate to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.

Please update the following components: Counter
 LOG  _handleClick - incrementing from 0 to 1
 LOG  _handleClick - counter is now 0
 LOG  _handleClick - incrementing from 1 to 2
 LOG  _handleClick - counter is now 1
 LOG  _handleClick - incrementing from 2 to 3
 LOG  _handleClick - counter is now 2

(although my log messages are already suspicious there)
It's late here so I'll be AFK till the morning, but I appreciate the look already.
Hopefully it's something obvious, I was enjoying getting rid of the lifecycle method deprecations, and was hoping I'd test the RC for you without stumbling on anything :-)

I see what's going on here - setState triggers a getDerivedStateFromProps, which runs _buildState. I think _buildState is going to need to have prevState passed to it - this.state probably can't be trusted in _buildState

The short-term mitigation is to remove setting current state back to state - but that's not a true "fix" - I'm investigating now but I think it'll involve passing state into buildState and having to use that

  _buildState(props, state) {
    if (!this.state || this.state.counter == undefined) {
      console.log('_buildState - no initial state, initializing to 0');
      return { counter: 0 };
    }
  return undefined;
  }

Didn't mean for this to auto-close - publishing rc.2, lemme know if that helps you

Good morning :-), I actually didn't see you'd published rc.2 until after I tested but I just added berickson1/resub#incomingState and built it in node_modules to test - same effect.

It does work with this code now as _buildState:

  _buildState(props, initial, state) {
    if (!state || state.counter == undefined) {
      console.log('_buildState - no initial state, initializing to 0');
      return { counter: 0 };
    } else {
      console.log('_buildState - counter already set to ' + state.counter);
      return { counter: state.counter };
    }
  }

👏 👏

(basically doing what the new README says - adding a state variable as a third variable, and referencing that local var instead of this.state)

I'm still a little confused on why referencing state immediately after setting it returns a stale value. That is unexpected and I may have just never noticed it before, but it is not a regression from v1->v2 it was there in v1

So I have three side notes for this issue:

  • npm test produces an ERROR but does not fail?
  • the README might get a migration guide linked around from other places with a fuller description
  • using state immediately after setting it is odd? [update: setState is mildly async, this is not a resub issue]

But clearly the actual issue is resolved, which is great. That was my only issue with rc.1 so I believe I can rely on it in my project. I'm not using it in a very complicated way, but it is fundamental to the project, so hopefully that helps as a test.

As a final side note I really appreciate how the team ships source ready to build with reactxp releases so that (if needed) you can use something like patch-package to patch the .ts files then as postinstall cd into node_modules && install && build. I may not have started contributing personally if that wasn't the case. I liked the style so much I copied it and made it a requirement when we migrated react-native-device-info to typescript - I made sure people had the source and a working build setup in their normal project install so I could capture what I think of as "drive by contributions" from library users (which usually start as node_modules hacks I think). I notice that resub doesn't do this but it seems close, it's just missing the .eslintrc and a couple other things and it would build.

Sorry, I'm full of side-notes. I just remembered that a separate breaking change was that I had a pattern (valid or not) in my constructors where I would:

this.state = {
  somevar: "initial state",
};

...initializing to the empty state during construction. Is that useful or is it an anti-pattern? Side-stepping that debate, it was not an error in v1, but in v2 it triggered

if (!internalState._resubGetInstance) {
throw new Error('Resub internal state missing - ensure you aren\'t setting state directly in component construtor');
}

So that was a breaking change for me. Easy to fix, but if there is a migration guide it might be an entry

Hey,
at first, you can't rely on a state update after calling setState. SetState is called asynchrounously.
Have a look here.

Secondly, with ReSub you should initialize your state inside the _buildState method, if 'initialBuild' is true. That is beacuse of ReSub setting some internal values to the State in the constructor of ComponentBase.

And yes, that could be stated in the migration guide, but in my opinion, the documentation stated that already clearly before: During initial component construction, initialBuild will be true. This is where you should set all initial state for your component..
So usage of this.state = .. worked before, but was not the way to use it (as i interpret the docs).

@BiggA94 thanks for that link on setState - I'm still getting up to speed on all things React so that is useful reading.

No disagreement that I should have used initialBuild - I conveniently side-stepped the "debate" (knowing it was the wrong style) in my comment, I just wanted to point out that it was a breaking behavior change in the context of a possible migration guide. The goal would be to reduce future support load for maintainers - any breaking change inevitably turns in to 2-3 issues in my experience if not documented explicitly as such

@mikehardy The more I think about your example, the more I think, the usage of buildState is not correct here.

You don't need to return the whole state in the _buildState, only the objects that should be updated.
In your case, you update your state with the call of the setState method.
If you want to log the counter, you should use the callback of the setState function.

Also I think I found a 'Problem' with the concept of v2..
React seems to not set the State before calling getDerivedStateFromProps.
So this leads to the breaking change, that you can't rely on this.state inside the buildState function.
Using the state, that is now provided via properties seems to fix this.
I think I will further investigate this, if i have some spare time..

@berickson1
One Problem with your update is, that you use Readonly<S> | {}.
If one is using an Interface, you can't check, if the state is of type S (only by checking every property).
I think it would be best to use Readonly<S> | undefined here.
The state is undefined on initialBuild, after that, it is of Type S.
This would improve usage greatly.

@BiggA94

@mikehardy The more I think about your example, the more I think, the usage of buildState is not correct here.

You don't need to return the whole state in the _buildState, only the objects that should be updated.
In your case, you update your state with the call of the setState method.

This was critical to untangling my work project, thank you.

Also I think I found a 'Problem' with the concept of v2..
React seems to not set the State before calling getDerivedStateFromProps.
So this leads to the breaking change, that you can't rely on this.state inside the buildState function.

This is where I was tangled. I found I was not even able to rely on the new state argument being updated after setState. But it was true (in my project) that there was separation between the items in state I was managing in the component with UI handlers and the items I that were in an external Store. So by returning Partial State objects from _buildState that were only concerned with the external Store, everything is working

in v1's method/render lifecycle, _buildState had access to an updated / correct state so it wasn't a concern.

@mikehardy I think I have to dig deeper into the react lifecycle to find the correct solution to this..
Seems, that not all is documented.. :D

Could you provide a repro, or show the code, where you see, that you couldn't rely on the new state argument?

Not quickly, but I think the heart of a minimal repro will be do have the setState call in the handler be asynchronous.

The setup on this screen is with react-navigation
I have a focus listener that calls an async method
That async method does a setState
When _buildState is called as a result of the setState in the async from the focusListener, the state provided as 3rd parameter in 2.0.0-rc.2 does not have the value I just set into it

It may be possible to mock this up with timers etc (that's how I'd do it) but unfortunately I'm out of time to play with it for a while

Just to clarify a little bit on expected internal behaviour or ReSub

  • Setting the entire State object in _buildState is somewhat of an anti-pattern. Ideally _buildState would only query your resub stores and grab state relating to that (except on initial build). Anti-pattern or not, we should still provide consumers as way to to this (the new incomingState argument on _buildState should fulfill this)
  • Calling setState triggers a getStateFromProps call before state is applied - which calls _buildState. This is why you were seeing an 'old' value in this.state while inside _buildState. We don't have any tests to cover this case - but we should add some to help prevent future regressions

As for Mike's last question - I tried doing a setTimeout before calling setState and couldn't repro :(
I can play around a little more this evening to see if I can find a repro

echoing that, my main takeaway is that I was dealing with state terribly (in my subjective opinion), so in that way the v1->v2 behavior change has been quite illuminating and resulted in a big project improvement.

Also I took a quick stab at a reproduction starting with the very code I saw it in and I must retract my assertion that the new 3rd argument was not updated. I can no longer reproduce it. I assume it was still a reference to this.state. vs state..

So I return to my previous assertion that with 2.0.0-rc.2 (and my state-handling reorganization) my project is working fine.

As part of this v2 moveover, we're continuing to try to simplify the happy path, and make it harder to hose yourself. So it's still really good for us to flesh out cases like this, and see how we can add more exceptions/checking to help people down the happy path.