FormidableLabs / freactal

Clean and robust state management for React and React-like libs.
MIT License
1.65k stars 46 forks source link

Breach of contract, A component manages and contains its own state #30

Closed tlvenn closed 7 years ago

tlvenn commented 7 years ago

I was reading the new article by @tptee on freactal which puts some emphasis on the breach on contract that for example Redux is forcing on us because a component should manage and contain its own state.

This kinda implies that freactal does not do this and therefore doesn't allow arbitrary descendants to pick state from ancestors. However the documentation says otherwise:

Because Child is contained within the subtree where Parent is the root node, it has access to the Parent component's state.

This seems to imply that no matter how deep the child is, if it's an descendant of the parent, it can access and mutate the ancestor's state which essentially breaks the encapsulation.

Now this is a super useful behaviour when you actually need to share state between components meaning the state does not belong to a given component. However when you do need to create a state that does belong to a given component alone, it does not seem that there is a way to do it right now and that's why i had opened #22 to discuss this.

So is my understanding on how freactal manage and provide state to ancestors wrong or is the article incorrect and little bit misleading in that regard ?

On a side note, while the example of child / parent / grand-parent has been fixed in the Readme on Github, the article is still using the incorrect one.

And don't get me wrong, I very much like this project and it's great article, I just find the stance on breach of contract a bit misleading because as far as I see it, freactal is in the same boat as redux when it comes to encapsulation of component's state for now.

zebulonj commented 7 years ago

I think @tlvenn is reading the documentation correctly (I've confirmed this be reading the source and experimenting), in that any descendent can access the state and effects of an ancestor provider (stateful component created with provideState()).

However, I do think that freactal is in a slightly different boat than Redux... in that in Redux the entire state is always composed into a single store, accessible to any connected component, whereas here you could choose to "provide" a fragment of the state at a much deeper level in the component hierarchy. A truly self-contained component shouldn't have any descendants that are outside its control, so there is no reason that its state would be available to other components.

tlvenn commented 7 years ago

in that in Redux the entire state is always composed into a single store

provideState does the same, everything is flattened into a single 'store'.

whereas here you could choose to "provide" a fragment of the state at a much deeper level in the component hierarchy

How is this different from mapStateToProps that Redux is providing or what Reselect will offer ? As far as I know, this is exactly the same principle.

A truly self-contained component shouldn't have any descendants that are outside its control, so there is no reason that its state would be available to other components.

Ya I totally agree, in that case, the sharing should be explicit by the mean of the component passing down its state to its childs if it should choose to but the child shouldnt be able to inject his parent state. Only when we can guarantee that, can freactal provide imho proper encapsulation for component state.

zebulonj commented 7 years ago

provideState does the same, everything is flattened into a single 'store'.

That's not my reading of the code.

Take this example:

import React from 'react';
import { provideState, injectState } from 'freactal';

const GlobalProvider = provideState({
  initialState: {
    globalCounter: 0
  },
  effects: {
    incrementGlobal: () => state => ({ ...state, globalCounter: state.globalCounter + 1 })
  }
})(( props ) => props.children );

const GlobalCounter = injectState(({ state, effects }) => (
  <button type="button" onClick={ () => effects.incrementGlobal() }>Clicks: { state.globalCounter }</button>
));

const LocalProvider = provideState({
  initialState: {
    localCounter: 0
  },
  effects: {
    incrementLocal: () => state => ({ ...state, localCounter: state.localCounter + 1 })
  }
})(( props ) => props.children );

const LocalCounter = injectState(({ state, effects }) => (
  <button type="button" onClick={ () => { effects.incrementLocal(); effects.incrementGlobal(); } }>Clicks: { state.localCounter } / { state.globalCounter }</button>
));

const App = () => (
  <GlobalProvider>
    <GlobalCounter /> // The global counter only has access to the state and effects from GlobalProvider.
    <LocalProvider>
      <LocalCounter /> // The local counter has access to the merged state and effects from both GlobalProvider and LocalProvider (because it is a descendant of both).
    </LocalProvider>
  </GlobalProvider>
);

React.render( <App />, document.getElementById( 'content' ) );

The GlobalCounter would only have access to state.globalCounter (not to state.localCounter) because it is a descendant of only GlobalProvider. In contrast, LocalCounter would have access to both state.localCounter and state.globalCounter (and both effects) because it is a descendant of both.

provideState() does not flatten the state at a global level. Each stateful component created with provideState() merges its state with the state of all of its ancestors. That merged state is provided to descendants, but the flattened state is not globally accessible.

Here's the relevant code:

buildContext () {
    const parentContext = this.context.freactal || {};
    const parentKeys = parentContext.state ? Object.keys(parentContext.state) : [];

    return this.middleware.reduce(
      (memo, middlewareFn) => middlewareFn(memo),
      {
        state: graftParentState(this.stateContainer.getState(parentKeys), parentContext.state),
        effects: this.effects,
        subscribe: this.subscribe
      }
    );
  }

Each stateful component captures the state of its ancestors via the React context, and merges in its own state. That merged state is passed to components created with injectState().

tlvenn commented 7 years ago

Yes you are totally right @zebulonj and I should had been more explicit when I said flattened. So indeed, freactal offers more isolation out of the box than redux by making accessible only the ancestors's states (which are flattened) and not any sibling or another leaf state which is definitely better. It's a nice benefit of colocating / providing state within your react tree.

Now it would be nice to be able in some cases to provide state to only the direct child and not to all the descendants.

divmain commented 7 years ago

@zebulonj thanks for jumping in!

@tlvenn agreed, it might be interesting to consider use cases where we don't wish to expose state atoms or effects to descendants. A public/private sort of thing. I'm going to close this issue out, but if you'd like to see something like that happen, please feel free to open a new issue with some fleshed-out use cases.

Cheers!