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

Investigate using Redux for pseudo-local component state #159

Closed
gaearon opened this issue Jun 21, 2015 · 54 comments
Closed

Investigate using Redux for pseudo-local component state #159

gaearon opened this issue Jun 21, 2015 · 54 comments

Comments

@gaearon
Copy link
Contributor

gaearon commented Jun 21, 2015

As @slorber notes, once you go single-treeish, local component state begins to bother you.

I don't feel strongly about it (I'm fine with some local component state here and there), but it would be interested to explore API and implementation-wise the idea of replacing React local component state with state backed by Redux with “ephemeral” reducers whose data is erased when their owner component unmounts.

I'm not sure if I'm actually making sense here.. Let's say that the litmus test is:

  • Have some API that looks similar to React's local state API. For example, a container component that injects state and setState as props.
  • Unlike React's local state API, under the hood, it works by emitting actions like { type: SET_LOCAL_STATE, ownerComponentId: '42424242' }
  • There is a reducer that handles these actions. The container component subscribes to the slice of the state governed by that reducer
  • (The nice part) By default component IDs are unique but you can specify a function that generates the key from props. This lets components “mirror” the same state or “transplant” it across the tree. (cc @chenglou)

Related: rethinking React's key and state.

@gaearon
Copy link
Contributor Author

gaearon commented Jun 21, 2015

Note that I think all of this can be implemented totally independent from Redux in a separate repo. Anybody wanna try?

@gaearon
Copy link
Contributor Author

gaearon commented Jun 21, 2015

The tricky part of course is to figure out when (and whether) to erase such state. This may be a bad idea after all, see @jimfb's comments in this gist. But I still think we ought to give it a try..

@gaearon
Copy link
Contributor Author

gaearon commented Jun 21, 2015

I think a great first approximation is to make it work so that the “pseudo-local” state is always destroyed when the component unmount. The set of “actions” would be MOUNT_LOCAL_STATE, SET_LOCAL_STATE, UNMOUNT_LOCAL_STATE. This is already cool because this enables time travel and logging at Redux level even for “local” state.

@gaearon
Copy link
Contributor Author

gaearon commented Jun 21, 2015

Here's something interesting. This is totally implementable outside core as long as the user doesn't forget to include our “local state reducer” in their global reducer. Doesn't this sound similar to what I wanted in #113? It's the same “third party” state and “third party” actions all over again. It sounds like there's a special sort of functionality many plugins want to have: the ability to write to a custom slice of sub-atom that user is not managing explicitly. @acdlite

@dariocravero
Copy link
Contributor

@gaearon we're almost doing that in panels (a runtime to run independent apps with a very particular (mobile focused) UX). However, because each app is meant to be independent, we isolate its flux instance in what we call contexts which is part of the runtime's atom. I should be in a position to show you what I just described in real code sometime around the conference if you're interested. I'm interested to see how this unfolds and how I can help shape it. :)

@gaearon
Copy link
Contributor Author

gaearon commented Jun 21, 2015

Cool, see you at the conf then!

@dariocravero
Copy link
Contributor

Definitely :)

@matystl
Copy link

matystl commented Jun 21, 2015

Similar ideas in clojurescript http://blog.circleci.com/local-state-global-concerns/

@steida
Copy link

steida commented Jun 22, 2015

After actions as pure functions, initial state, state-less flux, another stuff I'm using in http://github.com/steida/este Do we follow the same logic steps?

I'm calling that pattern globalized local state: https://github.com/steida/este/blob/13c59be76c39ecfabbf6f8ada17e8ffde0e59cbf/src/client/components/editable.react.js#L101

@iammerrick
Copy link

I don't know that it makes sense to couple transient state with the component. Maybe I'm misunderstanding but really I want transient state that potentially spans multiple components....

@gaearon
Copy link
Contributor Author

gaearon commented Jul 26, 2015

I don't know that it makes sense to couple transient state with the component. Maybe I'm misunderstanding but really I want transient state that potentially spans multiple components....

That's exactly what I'm suggesting. :-)

By default component IDs are unique but you can specify a function that generates the key from props. This lets components “mirror” the same state or “transplant” it across the tree.

@taylorhakes
Copy link
Contributor

My suggestion for this type of an API would be

@LocalState({ 
  keyFunc: (props) => //...
  reducers: [
    () => {}
   // ...
  ]
})
class MyContainer extends Component {
//...
}

The reason I like that slightly better is that you can use actions like normal, state is passed through props and you get the replay functionality. You also have the nice thing of not needing setState.

The child components also don't need to change based on whether they are using global or local state.

@CooCooCaCha
Copy link

I wonder if this could be used with the upcoming Stateless Functions feature in React?
https://github.com/reactjs/react-future/blob/master/01%20-%20Core/03%20-%20Stateless%20Functions.js

@taylorhakes
Copy link
Contributor

Either one of the proposals could be used with stateless functions. As long as everything is passed through props, which it is.

@taylorhakes
Copy link
Contributor

@gaearon I have created a successful experiment with pseudo-local state. It is located here
https://github.com/taylorhakes/redux-devtools/tree/local-global-state . Run the todo example app.

I will split it out into a separate repo shortly. The api closely follows my proposal mentioned earlier.

@taylorhakes
Copy link
Contributor

In the app I changed the edit text of TodoTextInput from component state to pseudo-local and the isEditing flag in TodoItem to pseudo-local

cef62 added a commit to cef62/redux-component-state that referenced this issue Aug 25, 2015
Current Code based on idea from this thread:
reduxjs/redux#159 and initial experiments from
https://github.com/taylorhakes
@cef62
Copy link

cef62 commented Aug 25, 2015

Following ideas from this discussion I've made a repo to support redux at component level: redux-component-state.

The current version is based on requirements of a specific project I'm working on but I want to add more features in the next weeks.

I'd like to have feedbacks and suggestions on it :)

@ashaffer
Copy link
Contributor

We have been having a related discussion over in anthonyshort/deku#218.

IMO the custom key generation thing is critical, so critical that I almost thing it might be a good idea to eliminate the default of randomly generating them.

I also don't really like the idea of a setState. Why create an imperative abstraction just so that components can have their own state? People can certainly create a SET_LOCAL_STATE action if they want, but it seems like a bad practice to encourage, and maybe it makes sense to do this so that you can have interoperability with existing React components, but it seems like these should be two separate libraries. One that provides the basic component lifecycle bound state with no default id and no imperative setState, and another that takes that primitive and rebuilds React's local interface on top of it.

EDIT: I'd also add that TBH to me, the entire concept of component <-> state mapping, feels like it is a bit of a red herring. It is something that is very natural to think about, but might actually be deeply impractical to actually use? I don't have a great proposal for an alternative, but it is a nagging feeling that I have, that state should really not be considered in terms of the UI tree at all.

@ashaffer
Copy link
Contributor

A good example to think through I think is a dropdown (as is discussed in the linked thread). A dropdown has a local state (open/closed), but that state is also global, because a click anywhere other than the dropdown should close that dropdown. Usually people will do something like document.addEventListener('click') from inside their nested dropdown component, but that is a global listener for what was supposed to be a piece of local state.

My best thought at the moment for this is to create a higher-order duck for dropdowns that can be parameterized with your application's 'uncaught click on document' action. This doesn't solve the problem of lifecycle management or anything like that though.

@timdorr
Copy link
Member

timdorr commented Sep 14, 2015

The dropdown may be a bit of a red herring.

The problem with document.addEventListener is it is a side effect, so it can't be directly controlled by your store. And the listener, while applied to the global context, is only concerned with this local context of your dropdown component. You don't want to have to run every single click in your document through the dispatcher, because that will be non-performant. You should be unbinding that listener after you're finished.

I think that's outside the bounds of application state, which is how I use redux. The selected item in a list is application state. The fact that the list is expanded is component state. It's all about what state comes into the component externally. No one upstream of my component tree will tell my dropdown to open, so why would they need to know about it?

Maybe some redux-y or react-redux-y wrappers around local state would be better? Something to give it similar behavior to my application state, but applied and only accessible at the local level.

@ashaffer
Copy link
Contributor

@timdorr I think you are right about the use of document.addEventListener. That should be bound/unbound as needed by the action creator, I suppose.

However, why do you say noone upstream of your component might tell it to open? Also, i'm not sure I understand your distinction between 'application state' and 'component state' here. It is very possible that someone outside of your dropdown may need to know about it. Sometimes you want to move things around slightly when a dropdown is open, or do other things like that.

I think, in general, making distinctions between types of state is a very slippery slope, and the more we can unify it all into redux's global state atom the better.

@timdorr
Copy link
Member

timdorr commented Sep 14, 2015

If someone else needs to know about your dropdown state, then it's no longer local component state and is, instead, application state and should be managed by redux. However, if we're thinking declaratively, the fact that the state indicates an open dropdown is just an implementation detail of the component.

If you need to move things around, that sounds more like a CSS problem than an application problem. Or at the very least, that would be local state contained at a higher, but not top-level, container.

@ashaffer
Copy link
Contributor

@timdorr Ya, I think what i'm saying is that there isn't really such a thing as local component state, and that trying to create and enforce strong component state boundaries is a mistake.

Also, in relation to the document click issue, while I do think you're right about that in particular, I think the general problem is still valid. Your application has application-specific but global events that take place, and your sub-components may want to respond to those events, so I do think you need a way of parameterizing things like dropdowns with event types expressed in the language of your application. Examples might be closing modals/dropdowns on route change, or similar.

@timdorr
Copy link
Member

timdorr commented Sep 14, 2015

I disagree. There is inherently local state because of the actual rendered view (DOM, in this case) having some state itself. React does a great job of normalizing the behavior of that state, but there is an inherent externality to it. In particular, there is state we cannot manage, such as the value of file inputs.

I don't think redux should be the do-all, end-all source of state in the entire javascript environment. It doesn't handle side effects at all, so it cannot be the ultimate state authority. It's best for managing application state, so it should be limited to that use. Trying to map and synchronize every little bit of state throughout your components and the DOM back to a single location is undoubtedly never going to be performant and suffer from quirks of that translation.

I happen to enjoy the boundaries of component and application state, as they are rarely intermingled more than one level up my component tree. My smart components act as a gatekeeper to dumb components that don't concern themselves with external effects. They provide APIs for my containers to compose them together logically. YMMV but it's been working great for me.

@gaearon
Copy link
Contributor Author

gaearon commented Sep 16, 2015

We want little slices of state that are bound to the lifecycle of a particular component (or perhaps N components via reference counting?).

I was thinking whether it's possible to make a store enhancer that would let you "branch" ephemeral stores that have the standard { getState, dispatch, subscribe } API but are "mounted" to the real store.

@gaearon
Copy link
Contributor Author

gaearon commented Sep 16, 2015

e.g.

import { compose, createStore } from 'redux';
import ephemeralize, { mountStore, unmountStore } from 'redux-ephemeral';

let finalCreateStore = ephemeralize(createStore);
let store = finalCreateStore(reducer);

class SomeComponent {
  componentWillMount() {
    this.storeA = store.dispatch(mountStore('a'));
    this.storeB = store.dispatch(mountStore('b'));
  }

  componentWillUnmount() {
    store.dispatch(unmountStore('a'));
    store.dispatch(unmountStore('b'));
  }

  render() {
    return (
      <div>
        <Provider store={this.storeA}><SomeConnectedReduxComponent /></Provider>
        <Provider store={this.storeB}><SomeConnectedReduxComponent /></Provider>
      </div>
    );
  }
}

@ashaffer
Copy link
Contributor

Ya, mounting stores seems like a very clean abstraction to me. I'm just not sure how it would work implementation-wise. It seems like you could do the mounting with replaceReducer, but how would you unmount?

I think it's also important to ensure that even though that store is mounted, the component that mounted it doesn't 'own' the state. It's still accessible to all other reducers and components.

@gaearon
Copy link
Contributor Author

gaearon commented Sep 17, 2015

Ya, mounting stores seems like a very clean abstraction to me. I'm just not sure how it would work implementation-wise. It seems like you could do the mounting with replaceReducer, but how would you unmount?

The mounted things aren't real stores—they're just projections with a Store-like API. In reality, they're like

// lib code

getState() {
  return realStore.getState().localStores[localStoreKey];
}

dispatch() {
  return realStore.dispatch({
    type: 'LOCAL_STORE_ACTION',
    key: localStoreKey,
    action: action
}

subscribe(listener) {
  return realStore.subscribe(listener); // bonus: whether realStore.getState().localStores[localStoreKey] changed
}

Therefore there is no need for replaceReducer—just make sure to mount a localStoreReducer:

// app code

combineReducers({
  normalTodos: todos,
  localStores: createLocalStoresReducer()
})
// lib code
function createLocalStoresReducer(state, action) {
  switch (state) {
  case CREATE_LOCAL_STORE:
    return { ...state, [action.key]: action.reducer(undefined, { type: 'init' }) }
  case LOCAL_STORE_ACTION:
    return { ...state, [action.key]: 
     // lol whatever I haven't thought this through
  }
}

as you see I have no real idea what I'm talking about lol. But if it works with record/replay and time travel then it's done right.

@ashaffer
Copy link
Contributor

Ahh yes of course. Ya that's basically what i'm doing in redux-ephemeral, but exposing an api that looks like mounting stores is nice.

I think one problem with it would be the extra subscribes. The mounting interface implies that components may subscribe to their little sub-store, but if you have already subscribed your whole app at the top of the tree, you might get duplicate updates.

Also, I think maybe the localization of the ephemeral stores that I am doing in redux-ephemeral right now might actually be a mistake. Action filtering by key should maybe be curried into the reducer if people want that, so that the ephemeral reducers may still respond to global actions. E.g.

function componentWillMount () {
    this.storeA = store.dispatch(mountStore('a', makeReducer('a')));
    this.storeB = store.dispatch(mountStore('b', makeReducer('b')));
}

function makeReducer (key) {
  return (state, action) => {
    if (action.type === GLOBAL_ACTION) {
     // respond locally to a global thing
    }
    if (action.key === key) {
      switch (action.type) {
        // respond locally to a local thing
      }
    }

    return state
  }
}

Makes a little more work for the user, but adds a lot of flexibility I think. Especially in the realm of higher-order components that you can parameterize with the application's global action types.

@ghost ghost mentioned this issue Sep 18, 2015
@AndyMoreland
Copy link

I haven't packaged this up nicely or anything but I have independent arrived at a solution that looks very similar to the code @ashaffer provided above.

I've found that my "subcomponent reducers" do in fact need to respond to global actions in addition to local actions in order to get things done, and I do end up having a sort of global "public action API" that subcomponents can depend on.

My solution differs from ideas discussed above in that I consider the subcomponent state private to the component in question even though it is stored in the global store. There is nothing that stops you from looking up a subcomponent's state if you know its componentId, but it breaks the encapsulation that I want to enforce and I avoid doing this.

More information:

I implement runtime mounting of reducers by registering a reducer for each subcomponent when it is first connect'd (I wrote a subcomponentConnect function that does this for me) and by dispatching actions to it if they are tagged with an appropriate componentId.

This subcomponentConnect function wraps the standard store.dispatch and wraps any actions generated by subcomponents with a SUBCOMPONENT_ACTION action that includes a componentId. The global reducer unpacks these and handles the dispatching as noted above. My global de-thunking and logging middlewares also understand SUBCOMPONENT_ACTIONs and do intelligent things.

The subcomponentConnect also implicitly includes a selector: whenever the wrapped subcomponent is rendered, its state is merged into the props.

In my solution I do not support subscribe for subcomponent state as a first-class thing: I considered this briefly but found that I did not require it anywhere in my application. I might include it again in the future.

@ashaffer
Copy link
Contributor

I realized today, and i'm not sure if you were suggesting this already @gaearon, but what you really want is for parent component's to mount their children, not for components to mount themselves. E.g.

// Let's say this is a nav component
function beforeMount (props) {
  // props.stateKey is an identifier that represents our current component's local state in the tree
  actions.createEphemeralStore(props.stateKey, 'dropdown', Dropdown)
}

function render (props) {
  return <Dropdown state={props.dropdown} />
}

Then that component's parent is doing something like:

function beforeMount (props) {
  actions.createEphemeralStore('nav', Nav)
}

function render (props) {
  return <Nav {...props.nav} />
}

So dropdown itself doesn't actually create or destroy any ephemeral state. Though it could export a creator for its own state export createDropdown = key => actions.createEphemeralStore(key, dropdownReducer).

It's slightly syntactically cumbersome relative to components just automatically getting their own state, but I think it makes the couplings really explicit in kind of a nice way. Your pre-mounting function then identifies all of your sub-components that have state, and can put them wherever it deems most appropriate - and also then by definition has access to them.

It's also super easy to create nice decorators for the mount/unmount hooks to make the syntax really minimal.

PS: Sorry I keep using non-react hook names and syntax, I don't use it so I can never remember their names for these things when i'm writing this stuff. I hope everything is still clear.

@ashaffer
Copy link
Contributor

Alright guys, I have a working solution for vdux, and a strong primitive for redux, I think:

  • redux-ephemeral - This is a low-level local state library. You can create, destroy, and update pieces of local state and dynamically assign reducers to it. You could use this directly in your components, but it'd kind of suck.
  • vdux-local - Uses redux-ephemeral and composes around your component to automatically create and destroy local pieces of state based on the key prop. Also exports a localAction action-creator creator so your components may export actions that pertain to them, and you can direct those actions to them by passing the appropriate key. By default it will pass along a setState to your render function and all your hooks, just like react. But you may also export your own reducer and action creators, and they will be composed.
  • virtual-component - A wrapper around thunk creation for virtual-dom that gives you a more react/deku like API with hooks, shouldUpdate and all that good stuff.

There is a working, though extremely non-pretty example in vdux right now. It is the 'todo' example. It should be pretty straightforward to create a variant of vdux-local on top of redux-ephemeral for whatever your preferred virtual dom framework is.

@timaschew
Copy link

I don't get it, why it's bad to use setState of React?

@dshimkoski
Copy link

One example would be application state that gets sent to a logger when an exception is caught. If you hide transient state in the component as an implementation detail, you may not be able to reproduce the issue, since the necessary condition may only be present when you click on X while Y and Z are open but A and B are closed.

@ashaffer
Copy link
Contributor

@timaschew Partially it's just the aesthetics of truly having all state in one place. There are some practical benefits though:

  • Logging, as @dshimkoski mentioned.
  • Global undo, time-travel, etc..
  • Manipulating state programmatically, e.g. copying state from one component to another, reparenting a component in another place in the tree
  • Freeing state from React more generally. When state is trapped inside react, it's this thing you can't reason about and metaprogram around - you're stuck with the limited paradigm they give you, which admittedly covers a lot of cases, but it does prevent people from exploring alternative state management abstractions, or creating more interesting state handling patterns and libraries.

Another thing to point out is that react components are not actually pure - they own their own state. Migrating state into redux allows you to have a truly pure render function, if that is important to you.

@timaschew
Copy link

okay, I understand if the you have at least one of the needs you listet.
But for components which has a really simple UI state, which don't need to be "reload safe" and which you don't need to transfer or to log and this state is also completely standalone, for instance:

a component with a help icon, and if you click on that icon you want to display a help text, the visibility is toggled on each click on the help icon.

I don't see here a reason to pass this to the whole redux flow

@ashaffer
Copy link
Contributor

ashaffer commented Oct 1, 2015

@timaschew Ya, I think it just comes down to what is important to you. I certainly wouldn't say that it's wrong to use React's local component state. But the ultimate goal here, for me at least, is to have the primitive ui = app(state), where app is a pure function that is 1:1 with values of state. When you have local component state, app(state) may return different values for ui based on what's in those local components' states, which destroys all of the guarantees of functional purity.

In your help example, that little piece of local state in your help component infects the entire tree above it. Now everything that contains help is impure, and you can no longer infer from its arguments exactly what it will return - you have to actually run it to see.

The benefits of doing this are definitely a little abstract for now because the benefits of it are largely in more powerful abstraction, code clarity (ofc a matter of opinion here), tooling, and automated verification capabilities.

@gaearon
Copy link
Contributor Author

gaearon commented Oct 5, 2015

Thanks for the discussion! I'm closing this as inconclusive.
A better idea might be to work on enhancing React state model: facebook/react#4595
Pseudo-local state adds too much indirection for my taste, but if you're fine with it, see approaches above.

@gaearon gaearon closed this as completed Oct 5, 2015
@rygine
Copy link

rygine commented Oct 7, 2015

A little late to the party, but I put something together and it seems to work quite well. It needs a lot of refinement. I'm sure there's a better way to do this.

I overrode setState so that components can be used in other projects, outside of Redux.

https://gist.github.com/rygine/4e6c08e32c9760249d66

@tonyhb
Copy link

tonyhb commented Dec 8, 2015

Also made a small library adapting react-redux via decorators for UI management. It's a WIP but seems like it will work well for our projects at Docker: https://github.com/tonyhb/redux-ui

@gaearon
Copy link
Contributor Author

gaearon commented Feb 12, 2016

There’s been some interesting work in https://github.com/threepointone/redux-react-local.

@threepointone
Copy link

Thanks for the mention, Dan. I got some work into this over the weekend, and it seem to match up with your original RFC. A couple of notes -

  • you can't have have two components with the same ident alive on the page at the same time(because this would also imply 2 reducers for the same key, which wouldn't make sense). That said, you could maybe still transplant/mirror the state by simply using @connect(state => state.local[ident])and piping it to the same dumb rendering component the original component uses.
  • when registering local stores, I'm sending functions in the action payload, and even storing them in the redux atom, which would usually be a no-no in the redux world, if only because it would break server side rendering. However, because just rendering the component creates the sideeffect of registering the reducer, we can simply remove the reducers from the atom before serializing it and sending it over the wire to hydrate an app. On the client side, they'd kick in when rendered down, pick up the hydrated data, and carry on. So, it should just mostly work, but I'd have to actually try and see how.

@threepointone
Copy link

I take back what I said about 'transplanting', got it to work just fine. You can now render the same component anywhere and it syncs state/behavior, just fine. A gif -
4xrqefdnev

for this code - https://github.com/threepointone/redux-react-local/blob/master/example/transplant.js

@threepointone
Copy link

server side rendering/rehydration seems to work fine ootb too, except when you need to dispatch actions/prepopulate these stores. For that, I had to introduce 2 small helpers to 1. populate a store with local reducers 2. 'sanitize' a store's state to be JSON.stringify safe. 'working' example here https://github.com/threepointone/redux-react-local/blob/master/example/server.js

@denis-sokolov
Copy link

redux-cursor project might be of interest to readers in this issue. It’s an implementation of cursors avoiding the primary disadvantage gaearon notes.

@gcazaciuc
Copy link

gcazaciuc commented Aug 30, 2016

A new approach that might be of interest on this front is redux-fractal .

@lastmjs
Copy link
Contributor

lastmjs commented Aug 30, 2016

This web component allows for separate stores per component: https://github.com/lastmjs/redux-store-element

Multiple components can share the same store by name, and all actions fired from a component are scoped to the store that the component has attached itself to.

<redux-store on-statechange="mapStateToThis" store-name="VIDEO_COMPONENT_STORE"></redux-store>

@kuon
Copy link

kuon commented Sep 1, 2016

I'm adding this to the discussion: gcazaciuc/redux-fractal#1

It's about accessing parent component state from children.

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

No branches or pull requests