reduxjs / redux

A JS library for predictable global state management
https://redux.js.org
MIT License
60.9k stars 15.27k forks source link

how to compose redux reducers with the same actions? #897

Closed ccorcos closed 9 years ago

ccorcos commented 9 years ago

I'm just getting started with redux and it looks amazing, but I'm a little worried about abstraction.

Suppose I have a really simple input component

inputReducer = function(state='', action) {
  if (action.type === 'ON_CHANGE') {
    return action.value
  }
  return state
}

inputOnChangeAction = function(e) {
  return {
    type: 'ON_CHANGE',
    value: e.target.value
  }
}

Now its trivial to hook this up to a component with an input.

reducer = combineReducer({
  username: inputReducer
})

But what happens when I have more than one input?

reducer = combineReducer({
  username: inputReducer
  password: inputReducer
})

Ok, I suppose we could change the action type to specify which input its referring too.

inputReducer = name => (state='', action) => {
  if (action.type === 'ON_CHANGE_'+name.toUpperCase()) {
    return action.value
  }
  return state
}

inputOnChangeAction = name => (e) => {
  return {
    type: 'ON_CHANGE_'+name.toUpperCase(),
    value: e.target.value
  }
}

reducer = combineReducer({
  username: inputReducer('username')
  password: inputReducer('password')
})

But now suppose I place two of the same views side-by-side? Now I need to modify the action yet again to specify which input in which view I'm referring to.

inputReducer = index => name => (state='', action) => {
  if (action.type === 'ON_CHANGE_'+name.toUpperCase()+'['+index+']') {
    return action.value
  }
  return state
}

inputOnChangeAction = index => name => (e) => {
  return {
    type: 'ON_CHANGE_'+name.toUpperCase()+'['+index+']',
    value: e.target.value
  }
}

reducer = function(state:[{username:'', password:''},{username:'', password:''}], action) {
  return state.map({username, password}, index) => {
    return {
      username: inputReducer(index)('username')(username)
      password: inputReducer(index)('password')(password)
    }
  }
}

Oy vey. Whats tough about this is that the input reducer somehow needs to know about how the rest of the app is structured. That just doesnt seem right. Ideally we would be able to abstract out the input's actions and reducers based on how the parent decides to arrange them. I suppose we could do this with a high-order function. But this is all getting pretty tedious. Am i missing something? How are you dealing with this?

Nicktho commented 9 years ago

redux-form is a great tool to use to handle forms.

Reducers can pass to other reducers. The basic idea here is to create a high-order reducer that delegates down to further reducers for each group of inputs you have. Those reducers could act on generic actions such as ON_CHANGE. Remember, you can include whatever data you would like in an action, so it's not necessary to have a reducer for each field in a group, just include that data in the action.

A very striped back version of this could be:

inputReducer = function(state={}, action) {
  if (action.type === 'ON_CHANGE') {
    return {
      ...state,
      [action.field]: action.value
    };
  }
  return state;
}

formReducer = function(state={}, action) {
  const { form, ...rest } = action;
  if (form) {
    return {
      ...state,
      [form]: inputReducer(state[form], rest)
    };
  }

  return state
}

inputOnChangeAction = function(form, field, value) {
  return {
    type: 'ON_CHANGE', form, field, value
  };
}
ccorcos commented 9 years ago

I see. That nice little snippet. I'm going to use that.

I'm still just a little confused though. One of the things I like to consider when building a web application is if I can have two versions of app running side by side in different divs. So long as there isnt user auth and browser cookies that are inherently global, then a well-written application (without globals) should be able to do this.

The problem I'm having with Redux when approaching this problem is suppose I build a todo's app. The entire todo's app is done. Ok, now I want to have two todos apps side-by-side. It should be really easy, right? Well it seems I'll have to change all of my reducers to be aware of the fact that there are multiple todos apps. Ideally, there would be some way of contextualizing each todo's app so they wouldnt have to be aware of where they are in the grand scheme of things.

ccorcos commented 9 years ago

Ideally you could do something like this:

App = React.createClass({
  render: function() {
    return (
      <div>
        <TodosApp id=1/>
        <TodosApp id=2/>
      </div>
    )
  }
})

And the id in there basically "lifts" all the actions, reducers, and state to reflect that...

ccorcos commented 9 years ago

I guess what I'm thinking is you'd have a store paired with each component. In this case, you'd have store for each TodosApp. Then hopefully theres some way of combining stores... but I'm not sure thats possible...

Nicktho commented 9 years ago

You could that. There's only ever one store in redux, just compose your app's main reducer in a higher reducer.

appsReducer = (state={}, action) => {
  const { app, ...rest } = action;
  if (app) {
    return {
      ...state,
      [app]: appReducer(state[app], rest)
    };
  }

  return state;
};

Then bind the id prop from TodosApp to any actions it dispatches as app: id

ccorcos commented 9 years ago

Hmm. I was under the impression that the actions should only be discriminated only by the "type" property. At least thats what it seems like. The Redux dev tool looks for the type, right?

What if the type was an array and the reducer was concerned only with the head of the type array. That way, it would be easier to abstract. Here's a solid example -- I'm very curious what you think...

We have a simple input action/reducer pair that concerns itself with a single input.

inputReducer = function(state='', action) {
  if (action.type[0] === 'ON_CHANGE') {
    return action.value
  }
  return state
}

inputOnChangeAction = function(e) {
  return {
    type: ['ON_CHANGE'],
    value: e.target.value
  }
}

The goal is to reuse those actions and reducers to abstract up to a form component like this:

Form = React.createClass({
  render: function() {
    return (
      <input value={this.props.username} onChange={this.props.usernameOnChange}/>
      <input value={this.props.password} onChange={this.props.passwordOnChange}/>
    )
  }
})

So what if we has these high-order functions to lift the actions and reducers by adding a new type to the beginning of the type array.

liftAction = function(type, f) {
  return function(arg) {
    let action = f(arg)
    action.type = [type, ...action.type]
    return action
  }
}

liftReducer = function(type, f) {
  return function(state, action) {
    if (action.type[0] === type) {
      return f(state, action.splice(1))
    } else {
      return state
    }
  }
}

This the top-level actions and reducers work like this:

reducer = function(state={username:'', password:''}, action) {
  return {
    username: liftReducer('username', inputReducer)
    password: liftReducer('password', inputReducer)
  }
}

actions = {
  usernameOnChange: liftAction('username', inputOnChangeAction)
  passwordOnChange: liftAction('password', inputOnChangeAction)
}

Does that make sense?

Thus for the side-by-side todo's example, we'd basically just do this:

reducer = function(state, action) {
  return {
    todos1: liftReducer('todos1', todosReducer)
    todos2: liftReducer('todos1', todosReducer)
  }
}

// liftActions just maps liftAction over the object values...
todos1actions = liftActions('todos1', todosActions)
todos2actions = liftActions('todos2', todosActions)

Does that make sense? Is this formalized in any way?

Nicktho commented 9 years ago

Almost, though instead of adding anything to the type, you can just add it to the action itself.

Again, refering to redux-form, here's an example helper that is similar to your liftAction but instead of adding to the type, it just adds a field to the action: https://github.com/erikras/redux-form/blob/master/src/bindActionData.js

Therefor, you wouldn't need to a liftReducer, you would just have a top-level reducer that takes those added fields to an action and delegates the rest of the action to the proper area in the state tree.

That being said, what's great about Redux is that it is as un-opinionated as it gets, which means if there is a viable alternative, by all means go for it, I'm just pointing out how these problems have already been solved for reference.

gaearon commented 9 years ago

I think this is pretty much the same as https://github.com/rackt/redux/issues/822. You can get full reusability with more strict Elm-like architecture, but this is incompatible with middleware so you need to choose which is more important to you.

ccorcos commented 9 years ago

@Nicktho thanks for the info. redux-form is interesting, but I'm still learning Redux so that redux-form is too much of a black box for me right now...

@gaearon thats exactly what I was looking for -- that's the essence of the question I'm asking here.

gaearon commented 8 years ago

Relevant new discussion: https://github.com/reactjs/redux/issues/1528

ccorcos commented 7 years ago

its been a while, but I just had an idea and put together a little demo: https://github.com/ccorcos/reduxish

Its basically adopting a the elm 0.16 architecture to redux.