Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow callback style setState #1

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

StevenLangbroek
Copy link

@StevenLangbroek StevenLangbroek commented Feb 1, 2018

This PR allows the "callback" style setState, which React also allows. Where React allows it for asynchronicity, the change here would allow advanced users who are aware of (im)mutability concerns (which you take care of with Object.assign, to a certain extent) to make all state changes with a single pass of lodash.flow or R.evolve:

import { flow, update, set, add } from 'lodash/fp';

class MyState extends Container {
  increment() {
    this.setState(flow(
      update(['nested', 'counter'], add(1)),
      set(['didIncrement'], true)
    ));
  }
}

"Advanced" (imagine airquotes, thanks :) ).

import { flow, update, set, add, subtract, multiply } from 'lodash/fp';

class MyState extends Container {
  constructor() {
    super();

    this.increment = this.createSetState(add, 1);
    this.decrement = this.createSetState(substract, 1);
    this.double = this.createSetState(multiply, 2);
  }

  createSetState(method, count) {
    return () => this.setState(flow(
      update(['nested', 'counter'], method(count))
    ));
  }
}

Todo

  • decide on Object.assign when using callback style
  • add tests
  • write documentation
  • bump versions, publish etc.

@StevenLangbroek
Copy link
Author

Want me to write some basic tests?

src/unstated.js Outdated
setState(state: $Shape<State> | (State => $Shape<State>)) {
this.state =
typeof state === 'function'
? state(this.state)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this still be merged back into the existing state?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. In the example it would just allocate an unnecessary empty object and key-iteration round (with Object.assign), as lodash/fp / ramda take care of immutability.

Copy link

@jgoux jgoux Feb 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, I'd prefer it to stay that way so it reflects React's default behaviour.
Thanks again @jamiebuilds for this excellent project.

EDIT: Today I learned that the callback setState is merged back to the state! 💡

Copy link
Author

@StevenLangbroek StevenLangbroek Feb 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this?

  1. Run updater
  2. Check for null / undefined or identity equal to previous state
  3. If not, assign over defensive copy

step 2 would mean that if you bail early in your updater that decision is propagated to your components. @jamiebuilds?

@jamiebuilds
Copy link
Owner

Sure, sorry there aren't any already. I wanted to publish it before I stayed up to 2am

@StevenLangbroek
Copy link
Author

Haha fair enough. Will add tests + when we iron out the above comment appropriate documentation.

* and leaving old state in place.
*/
if (newState == null || newState === prevState) {
this.state = prevState;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this even necessary? Or should I just return?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assignment indicates that it changes something, I think return would be more appropriate here

* propagate correctly through component tree by not calling _listeners
* and leaving old state in place.
*/
if (newState == null || newState === prevState) {
Copy link

@benwiley4000 benwiley4000 Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think returning early if a shallow comparison fails would be a breaking change, and incompatible with React's setState behavior. React docs discourage returning mutated state from the setState updater function (or passing a mutated state object to setState), but neither is prohibited. From the React docs:

setState() will always lead to a re-render unless shouldComponentUpdate() returns false.

An example demonstrating a successful state-mutation update is here.

My second concern is that React emits its own development-time console warnings for passing null to setState, and by returning silently we might prevent a developer from realizing they have made a mistake.

For both of those reasons, I think it might be best to eliminate the conditional early return altogether, and let React do its thing.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in other words, I would think about rolling back ff77c96.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setState() will always lead to a re-render unless shouldComponentUpdate() returns false.

I think this is not true with react@16. I don't use this feature but returning null is supposed to prevent scheduling a re-render.

What we might need to introduce here is 2 distinct paths for objects & function styles. Because of the null issue we cannot combine both and use newState in following lines, it should be restructured to something like:

  setState(state: $Shape<State> | (State => $Shape<State>)): void {
    const prevState = this.state;
    let newState
    if (typeof state === 'function') {
      newState = state(prevState)
      
      if (newState === null) {
        return;
      }
    } else {
      newState = state

      if (process.env.NODE_ENV !== 'production' && newState === null) {
        console.warn('write a nice dev warning here')
      }
    }

    if (newState === prevState) {
      return
    }

    this.state = Object.assign({}, prevState, newState);
    this._listeners.forEach(fn => fn());
  }

Does it make sense to u?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I understand how to get there code-wise, I'd just like to have @jamiebuilds's input on what the desired behavior is.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, the API should mirror React's API as closely as it can. So I'd say we should just follow that path.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah since this is already only conceptually similar to setState, I'm not convinced we should be following React to the letter vs doing the principally right thing. Their API tradeoffs aren't necessarily the same as those of a pure state manager. I'll update tomorrow, and hopefully get some time in to write those tests.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want it to match the React behaviour as closely as possible. Refactoring component state into container state should be near seamless

Copy link

@benwiley4000 benwiley4000 Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow my mistake! I was operating under the impression the Subscribe component's onUpdate() subscription callback was receiving the updated state and passing it to setState(). I should have read more closely. 🙂

@StevenLangbroek Given the above discussion I concede it makes sense to have custom error reporting and perhaps disregard what React does or does not report as a mistake.

However, the documentation for Unstated is currently written without any specification for setState(). The implication is that setState should behave the same way you would expect already, having worked with React. That means:

  • null prevents a re-render
  • But equal object identity does not prevent a re-render.

If there's an intent to change that, it should be documented well, and also be considered a breaking change which would merit a new major version.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamiebuilds I missed your reply, but I guess I should have just let your comment sit. 😄 I appreciate that sentiment - it's my favorite thing about this library.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  if (process.env.NODE_ENV !== 'production' && newState === null) {
        console.warn('write a nice dev warning here')
      }

Hi @StevenLangbroek , can we make one constant file and manage this kind of string production ? because in code we are using this kind of thing repeatedly and so instead of doing repetitively we can handle it in one file use.

@bnolan
Copy link

bnolan commented Jun 25, 2018

Any progress on a fix for this?

* propagate correctly through component tree by not calling _listeners
* and leaving old state in place.
*/
if (newState == null || newState === prevState) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  if (process.env.NODE_ENV !== 'production' && newState === null) {
        console.warn('write a nice dev warning here')
      }

Hi @StevenLangbroek , can we make one constant file and manage this kind of string production ? because in code we are using this kind of thing repeatedly and so instead of doing repetitively we can handle it in one file use.

this.state = Object.assign({}, this.state, state);
setState(state: $Shape<State> | (State => $Shape<State>)): void {
const prevState = this.state;
const newState = typeof state === 'function' ? state(prevState) : state;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of "function", can we make it in a constant file and export it @StevenLangbroek

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants