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

Unified state #179

Closed
wants to merge 6 commits into from
Closed

Unified state #179

wants to merge 6 commits into from

Conversation

Niryo
Copy link

@Niryo Niryo commented Sep 28, 2020

  • Start Date: 2020-09-28
  • RFC PR: (leave this empty)
  • React Issue: (leave this empty)

Summary

I would like to suggest a unify API for creating a global, contextual and local states.
This addition to React will hopefully completely mitigate the need for 3rd party state management libraries, and will solve some of the biggest problems
of hooked functions- hooks depends on call order for inferring the state, and they cannot be shared with class components.

Basic example

/**
const globalState = React.GlobalState(initialState, actions);
const contextualTest = React.Context(initialState, actions});
const localState = React.State(initialState, actions});
*/
//example: 
const someState = React.GlobalState({count: 0}, (state) => ({
       getCount: () => state.count,
       increaseCount: () => state.count += 1
}));
console.log(someState.getCount()); // prints 0;
someState.increaseCount();
console.log(someState.getCount()); // prints 1;

//every component that will make use of this state, will be re-rendered on every change in the state:
function Bar() {
     return <p>{someState.getCount()}</p>; //will always display the correct value
}

As you can see, when you create a state, you get back the actions object (bound to that state), this way the only way to interact with the state is by using the actions.

Motivation

Hooks came to solve the problem of sharing stateful logic (local state) and they have greatly improved the usability of contextual state (useContext), but we still face the problem of sharing global state, and things are starting to get messy- there are now two different ways to define local state (this.state for and useState), two different API's for creating and consuming contextual state (React.Context) depending on if you are using hooks or class components, and if you need a global state you must use a 3rd party library and learn another API.

If you are using React Native with a native navigation solution, the only way to share state between screens is by using a 3rd party library for creating a global state (aka store), because different screens don't share the same React root. Instead of suggesting a new API for creating a global state with React, I think we can unify the way we are creating states, thus addressing the problem of creating global state and solving some of the problems that hooks introduced to the framework, all of this by keeping the API small and concise.

Detailed design

React will add 3 types of state object, all with the exact same API:

const globalState = React.GlobalState(initialState, actions);
const contextualTest = React.Context(initialState, actions});
const localState = React.State(initialState, actions});

As you can see, the api is the same, and the separation is for readability purposes only. We could introduce a single React.state() api, and the type of the state (global, contextual or local) would have been defined by how the state is being used, But I find it safer to introduce a separate command for every type of state.

Here is the formal declaration of such a state:

type ActionsObject<T> = {[key: string] : (state: T) => any};
function State<T>(initialState: T, actionGenerator: (state: T) => ActionsObject<T>): ReturnType<actionGenerator>;

Let's see how we will use those 3 types of state:

Global State

inside store.js

import React from 'react';
export const store = React.GlobalState({name: 'bob'}, (state) => ({
    getName: () => state.name,
    setName: (name) => state.name = name
});

usage:

import {store} from './store';
// class component example:
class Foo extends React.Component {
   render(){
     return (
        <View>
           <p>{store.getName()}</p>
           <div onClick={() => store.setName('alice')}></div>
        </View> 
      );
    }
}
// function component example:
function Bar() {
     return (
        <View>
           <p>{store.getName()}</p>
           <div onClick={() => store.setName('danny')}></div>
        </View> 
      );
}

In this example, both Foo and Bar will always present the same name, because they are using the same global state. when Bar will change the name to danny, Foo will immediately re-render and will present danny also.

Contextual State

inside some contextual store:

//inside someContext.js
import React from 'react';
export const someContext = React.Context({name: 'bob'}, (state) => ({
    getName: () => state.name,
    setName: (name) => state.name = name
});

usage:

import {someContext} from './someContext';
function ProfileCard() {
     return (
        <View>
           <p>{someContext.getName()}</p>
           <div onClick={() => someContext.setName('alice')}></div>
        </View> 
      );
}

As you can see, the usage of contextual state and global state is exactly the same. The only difference is that when we use a contextual state, React will make sure that this context was provided by some component upper in the hierarchy tree, and retrieve for us the correct instance of the state:

import {ProvideContext} from 'react';
import {someContext} from './someContext';
function ProfileList() {
   return (
     <ProvideContext context={someContext}>
        <ProfileCard/>
     </ProvideContext>    
   )
}

So there is a little bit of magic here. We are using the context as a global object, but behind the scenes, React will make sure that we are interacting with the correct instance. It's the exact same piece of magic that we have with useState for hooks.

Local State

import React from 'react';
export const localState = React.LocalState({name: 'bob'}, (state) => ({
    getName: () => state.name,
    setName: (name) => state.name = name
});

//class example
class Foo extends React.Component {
     return (
        <View>
           <p>{localState.getName()}</p>
           <div onClick={() => localState.setName('alice')}></div>
        </View> 
      );
}

//function example:
function Bar() {
     return (
        <View>
           <p>{localState.getName()}</p>
           <div onClick={() => localState.setName('alice')}></div>
        </View> 
      );
}

Again, same API, same magic behind the scenes that makes each component get the correct local state. What's interesting here, is that we can use as many local State objects as we want, and we don't need to pay attention to the call order, because we can uniquely identify the states, so no need for weird rules like "don't put custom hooks inside an if statement".

Additional Api

Support effects in local state:

In order to fully support the capabilities of hooks, we need to somehow mimic useEffect. Here is one suggestion:

const localState = React.LocalState(initialState, actions, effects);

The effects props is a function that receives the state, and also the props of the host component (the component that is currently using the local state). This function will be called on every render (and on unMount). The return value will be used as a cleanup, just like useEffect:

(state, props) => {
   subscribe();
   return () => unsubscribe(); 
};

This will allow the creation of an hooked state, thus achieving the same behavior of useState + useEffect encapsulated together. Personally, I am not a big fan of such a pattern, but in order to achieve feature parity with the current hooks implementation, we have to allow something like this for local states.
We also need to think about a convenient API for replacing the dependency array.

Implementation and performance

We should mimic the exact same performance optimizations of Mobx- For every component render, React will take a note of all the state properties that have been accessed, and will re-render the component on every change of relevant props.

Drawbacks

  • The API is a bit magical, but it's the same magic of Hooks (keeping track of the currently rendered component for retrieving the correct state instance);
  • The community is just starting to get used to the hooks API, and this API will again change some of the most basic concepts of React - the state. I don't think that this drawback should stop us from moving forward though, unifying the way we are using state should be one of React's top priorities, and will result with smaller docs in the future. I really think that this step is needed in order to clean some of the problems of hooks, and the lack of global state solutions.

Adoption strategy

This proposal should be fully backward compatible and it will be completely up to the users if they want to start using the unified syntax or not. In the future we could start deprecating older state API's.

Unresolved questions

  • Should we add the suggested support for effects in local state? If so, we need to think of a good way for replacing the dependency array.
  • The provideContext API is not complete yet. we need to think of a way to provide multiple context at the same time, while allowing overriding the initial state.

@facebook-github-bot
Copy link
Collaborator

Hi @Niryo!

Thank you for your pull request and welcome to our community. We require contributors to sign our Contributor License Agreement, and we don't seem to have you on file.

In order for us to review and merge your code, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

If you have received this in error or have any questions, please contact us at cla@fb.com. Thanks!

@facebook-github-bot
Copy link
Collaborator

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Facebook open source project. Thanks!

@Niryo Niryo changed the title Unify state Unified state Sep 28, 2020
@omeid
Copy link

omeid commented Oct 5, 2020

Instead of defining both getName and setName I would prefer a more declarative way of doing it. Perhaps something like

import React from 'react';
export const localState = React.LocalState({name: 'bob'}, (state) => ({
    name: [(state) => state.name, (state, name) => { state.name = name }]
});

And use it so:

class Foo extends React.Component {
     return (
        <View>
           <p>{localState.name}</p>
           <div onClick={() => localState.name = 'alice'}></div>
        </View> 
      );
}``

@Niryo
Copy link
Author

Niryo commented Oct 5, 2020

@omeid with this approach, you can't create a setter that takes two parameters so it's a bit limiting. In the original approach you can do something like this:

import React from 'react';
export const localState = React.LocalState({name: 'bob'}, (state) => ({
    updateUserName: (userId, name) => state.users[userId].name = name
});

@fantasticsoul
Copy link

I agree that unify the way of sharing global state, but not necessarily a built-in API, because that makes react have to think more and do more(maybe unify component lifecycle?), 3rd lib based on hook and class.setState is really enough.

the key problem is when you want to unify it with built-in api, user will want react do more and more, like dependency collection at runtime etc....

look at this example, class and function can share state and logic very easily
https://codesandbox.io/s/unify-class-and-function-r7pvh?file=/src/App.js:50-1207

import { run, register, useConcent } from "concent";

const delay = (ms=1000)=> new Promise(r=>setTimeout(r, ms));

run({
  oneModel: {
    state: {
      greeting: "hello concent",
      count: 1,
      loading: false,
    },
    reducer: {
      change(e) {
        return { greeting: e.target.value };
      },
      async asyncChange(playload, moduleState, action) {
        await action.setState({loading: true});
        await delay();
        return { greeting: 'hello concent', loading: false };
      }
    }
  }
});

@register('oneModel')
class ClsHello extends React.Component{
  render(){
    const ctx = this.ctx;

    const {state, mr} = ctx;
    return (
      <div>
        {state.loading && <h1>loading...</h1>}
        <input value={state.greeting} onChange={mr.change} />
        <button onClick={mr.asyncChange}>reset</button>
      </div>
    );
  }
}

function FnHello(){
  const ctx = useConcent('oneModel');

  const {state, mr} = ctx;
  return (
    <div>
      {state.loading && <h1>loading...</h1>}
      <input value={state.greeting} onChange={mr.change} />
      <button onClick={mr.asyncChange}>reset</button>
    </div>
  );
}

even including instance level lifecycle logic:
https://codesandbox.io/s/unify-class-and-function-forked-gst4t?file=/src/App.js:1165-1238

function setup(ctx){
  ctx.effect(()=>{
    console.log('didMount');
    return ()=>  console.log('willUnmount');
  }, []);

  ctx.effect(()=>{
    console.log('detect greeting change after rendered');
  }, ['greeting']);
}

@register({module:'oneModel', setup})
class ClsHello extends React.Component{/**  ... */}

function FnHello(){
  const ctx = useConcent({module:'oneModel', setup});
}

or module level lifecycle logic:

run({
  oneModel: {
    state: { /**  ... */},
    reducer: { /**  ... */},
    lifecycle: {
       // when first ins of oneModel module mounted will trigger this
       mounted: (dispatch, moduleState)=> dispatch('reducerFnName'),
      // when last ins of counter module unmount will trigger this
       willUnmount: (dispatch, moduleState)=> dispatch('clearup'),
    }
});

@Niryo Niryo mentioned this pull request Oct 9, 2020
Copy link

@ghost ghost left a comment

Choose a reason for hiding this comment

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

I believe that this is an easy thing to develop in the userland, Personally done that with react-wisteria and I'm migrating soon to useMutableSource to solve the context performance issues as a single source of truth (PR already opened in repo).

Before using the mutableSource I built it with only using Context and State and it's not that hard or slow so you won't feel any performance issues for most of the standard web apps.

Regarding the local state so you can extract the logic to a custom hook and share both state getters and setters between different components easily without the need of creating a new React primitive.

I believe that the powerful part in React is the minimal API it has, so adding more primitives will make it worse for newcomers.

@Niryo
Copy link
Author

Niryo commented Oct 9, 2020

@Attrash-Islam Unifying the state API should be a direct interest of React itself, and should not be left for being fixed by a 3rd party library. This RFC will lead to a smaller API in the future (you could deprecate useState and this.state), will greatly improve the usability of the Context API, and will solve some of the mentioned problems of Hooks. The capability of sharing a global state is just a bonus on top of the other benefits of fixing the current API, and we will get it for free.

Also, the suggested API cannot be implemented in user-land as is, because you don't have a way for interacting with the internal context behind the scenes.

On a different note- I think that sending us to a 3rd party lib in order to fix some of the core problems of React is not a good suggestion. The introduction of Hooks proves that making it easier to share stateful logic without using a 3rd party lib, is a top priority feature. This RFC is mainly intended to fix some of the problems that Hooks introduced, and to make some sense in the state API by generalizing it. We are not introducing a new concept to React, we are just improving current concepts, and on the long term reducing the API surface.

@ghost
Copy link

ghost commented Oct 10, 2020

@Niryo Not sure how it would take time for React to deprecate the this.state and useState in order to unify the state API, they even don't intend to deprecate the Class Components for multiple reasons one of them is that some people prefer them over hooks and to keep support for hundred apps running today. Deprecating the current state options will force millions of developers to rewrite millions of components all over again which is not a good approach from the business side, also React core team always tries to do as less breaking changes as possible so old components can work for companies that don't have time for technical refactors yet.

@Niryo Niryo mentioned this pull request Oct 14, 2020
@jaequery
Copy link

jaequery commented Dec 9, 2020

for me - the big question is, what are the downsides of a global state manager?

what is the core React team's consensus on it, are they against the idea of it? or are they just shying away from it due to backward-compatibility breaking changes?

@jordanwoodroffe
Copy link

The approach is heading in the right direction, only one concern is that state management isn't semantically appealing, keeping the hooks clean and simple to use is essential rather than adding ott complexity to existing hooks

@rmcsharry
Copy link

I arrived recently in the React world after a decade of using Angular. This idea reminds me of Angular's services for managing state (singletons that can be defined globally or at local component level). I find this proposal considerably easier to grok 👍

I know of a few Angular projects that ditched 3rd party redux libraries (eg. ng-rx) and reverted to using services; thus I can see the same happening in the React world were an API such as this to be added.

The comments re. backwards compatibility and deprecation are ofc very valid.

@rmcsharry
Copy link

rmcsharry commented Dec 16, 2020

for me - the big question is, what are the downsides of a global state manager?

The biggest downside I can think of is 'indirection', in the sense that state is changing somewhere else in the code base (ie not in the component). Also most global state management solutions require a considerable amount of boilerplate code.

Dan Abranov covered this here back in 2016.

But I'd also question what's the downside of NOT having a global state manager. This is direct from the redux website:

the simplicity can break down when we have multiple components that need to share and use the same state, especially if those components are located in different parts of the application. Sometimes this can be solved by "lifting state up" to parent components, but that doesn't always help.

It seems to me that the proposal here is a lot clearer than Redux, requires less code and also seems closer to the core patterns of React. Obviously there's still indirection, you just can't escape it with global state.

@jaequery
Copy link

jaequery commented Dec 17, 2020

can we have a shorthand from:

{store.getName()}

to:

{store.name}

i find the getter a bit redundant

@Niryo
Copy link
Author

Niryo commented Dec 19, 2020

@jaequery

for me - the big question is, what are the downsides of a global state manager?

This proposal is mostly about unifying the already supported states in React, aka context, hooks and class state. we we'll get the global state support for free.

@jordanwoodroffe

The approach is heading in the right direction, only one concern is that state management isn't semantically appealing, keeping the hooks clean and simple to use is essential rather than adding ott complexity to existing hooks

We already have state management in React: context, class state and hooks are all state management. Unifying those states, and closing the loophole of global state on the way, is not adding complexity- it's removing complexity:)

@Niryo
Copy link
Author

Niryo commented Dec 19, 2020

can we have a shorthand from:

{store.getName()}

to:
{store.name}

i find the getter a bit redundant

Take look at my comment above.

@gaearon
Copy link
Member

gaearon commented Aug 23, 2021

This proposal tries to do a lot of things at the same time, so it's challenging to respond to. In the spirit of #182, I'll try to briefly respond to individual things that make it very unlikely to proceed.

Let's start with the premise.

will solve some of the biggest problems of hooked functions- hooks depends on call order for inferring the state, and they cannot be shared with class components.

We don't consider the second one ("Hooks don't work in classes") to be a problem at all. We're moving towards deemphasizing classes. While classes continue to work and are not deprecated at the moment, it's pretty clear that the community has embraced and largely moved to function components and Hooks for new code. While Hooks undoubtedly have some warts, we're not going to go back to writing classes, and any problems with Hooks will need to be "fixed forward". Using Hooks in classes was intentionally decided against in the first release because we consider mixing paradigms within one component too confusing. It's not a technical limitation, but a design decision. If we wanted to, we could enable using Hooks in classes today — without this proposal — but we just don't think it's a good idea or something to strive for. So this part of the motivation is not relevant.

As for the first one ("hooks depend on call order"), while it's slightly confusing at first, we don't find this to be a problem in practice. Your description of the solution ("same magic behind the scenes that makes each component get the correct local state") does not provide any details, so it is very unclear how it's supposed to solve the actual issue. Using object for identification would not allow you to write Custom Hooks, which is the main motivation behind relying on call order and the current design. Unless you rely on call order, that is, which defeats the point you're making. I wrote an article a while ago about flaws of different alternative proposals, the one I'm referring to here is flaw #3. Custom Hooks work because each call to useState gets isolated state. The solution you're proposing would break that.

Further, from the motivation section:

there are now two different ways to define local state (this.state for and useState), two different API's for creating and consuming contextual state (React.Context) depending on if you are using hooks or class components

This, again, has no relevance in a world where classes are deemphasized and only exist as a legacy concept. In fact, what this proposal would do is add a third way to use all of those features. Presumably, there needs to be a backwards-compatible way to get to the final state of this proposal, and until we get there (and migrate all of the class-based and Hooks-based code), this proposal makes the fragmentation worse, not better.

if you need a global state you must use a 3rd party library and learn another API.

The notion of "global state" is debatable and deserves its own detailed discussion. I don't think it's as simple as this proposal describes. This becomes clear when you take a smaller app, and then try to integrate its components into a larger app. Suddenly, things may not be so "global" anymore, and there's a question of how much refactoring you have to do to fix them. But I agree that there are real ergonomic problems with what people tend to perceive as "global" state. I suggest to look at them in more scoped proposals, such as #130 to which I have responded.

If you are using React Native with a native navigation solution, the only way to share state between screens is by using a 3rd party library for creating a global state (aka store), because different screens don't share the same React root.

I empathize with the issue but this sounds like a problem that would be best solved at a different level. Specifically, they should share the root in my opinion. Technically, this could be achieved with portals or some other means. There are other use cases for Context that have nothing to do with state management (e.g. theming), and even if you solved "global state", those use cases would still be unsolved for apps using native navigation. So I suggest to handle this problem from the other end, and to look for a way (or ask RN to provide a way) to have a single root for apps using native navigation.

Now, a few thoughts on the actual proposed API. Here's a few problems I see with it:

  • Like noted above, it doesn't allow for Custom Hooks without reintroducing dependency on the call order (which is not safe without a lintable Hooks-like API). This seems like a deal-breaker.
  • In general, Custom Hooks aren't addressed at all here. They're the most important feature that comes out of the Hooks design. It's unclear how state isolation would work, how calling the same Custom Hook twice would work. This applies to effects too.
  • It is pretty verbose for the simple case of adding just a few state variables. For the simple case, writing a setter and a getter is a lot of boilerplate we were trying to avoid. It doesn't minify well either because you've introduced dynamic dispatch (like something.getName() instead of just name which a minifier can mangle).
  • For context, this solution doesn't consider that Context isn't always backed by state. It's perfectly valid for Context to be "read-only" and a result of some transformation, e.g. <Context value={items.map(someFn)}>. This proposal doesn't make it possible in the current form because the value has to be known in advance and is essentially treated as coupled to state.
  • In this proposal, effects are an afterthought. They're always coupled to some state (which isn't true in general). They can only access state from "their state", which is too restrictive and makes them difficult to expand in scope and meaning. They can't access values from the render scope that are neither props nor "their state" — but that's one of the main motivation behind keeping effects in the render scope in the first place.
  • This proposal seems to emphasize "unification" as the main benefit, but in practice it would likely lead to more fragmentation. This is not addressed. It's unclear what the plan and the incentive is for people to convert from Hooks-based code to this. From classes to Hooks, the incentive was clearer: Custom Hooks and less boilerplate for common features. If anything, I would say that the "unification" aspect is already handled by the Hooks proposal. They did unify state, effects, and context. So if your primary interest is the space of "global state" features, I suggest to think of it as a Hook rather than as a separate mechanism trying to replace the entire API. In that vein, createSharedState #130 is a more promising proposal.

I realize this might not be as detailed as you'd like, but I hope this explains why this proposal isn't workable. I think you'll find that if you try to fix the first few problems with it, the API will become even more verbose, and Hooks will look much lighter by comparison. Still, I agree that the "global state" use case (even if we don't quite agree on what it is) could benefit from some sort of a first-party solution. I suggest to provide feedback in #130 and maybe experiment with similar ideas in user space. A proposal that tries to replace the primary React API needs to be a lot more detailed and consider a broad range of use cases (such as Custom Hooks, which are one of the most important features), so I don't think it's a good way to start.

@gaearon gaearon closed this Aug 23, 2021
@flyskywhy
Copy link

Ref to https://joshcollinsworth.com/blog/antiquated-react , maybe use Preact Signals instead of React Hooks in React APP is a choice.

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

Successfully merging this pull request may close these issues.