Proposal: declarative reducers #1024

Closed
ujeenator opened this Issue Nov 12, 2015 · 21 comments

Comments

7 participants
@ujeenator

I propose provide option for defining reducer with a plain JS object where keys are action types and functions defined with ES6 arrow functions (which leads to less code):

Here code example based on of Redux TodoMVC reducer.

How it declared now:

const initialState = [
  {
    text: 'Use Redux',
    completed: false,
    id: 0
  }
]

export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        {
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
          completed: false,
          text: action.text
        }, 
        ...state
      ]

    case DELETE_TODO:
      return state.filter(todo =>
        todo.id !== action.id
      )

    case EDIT_TODO:
      return state.map(todo =>
        todo.id === action.id ?
          Object.assign({}, todo, { text: action.text }) :
          todo
      )

    case COMPLETE_TODO:
      return state.map(todo =>
        todo.id === action.id ?
          Object.assign({}, todo, { completed: !todo.completed }) :
          todo
      )

    case COMPLETE_ALL:
      const areAllMarked = state.every(todo => todo.completed)
      return state.map(todo => Object.assign({}, todo, {
        completed: !areAllMarked
      }))

    case CLEAR_COMPLETED:
      return state.filter(todo => todo.completed === false)

    default:
      return state
  }
}

How I propose to declare:

export default {
  initialState: [
    {
      text: 'Use Redux',
      completed: false,
      id: 0
    }
  ],

  [ADD_TODO]: (state, {text}) => [
    {
      id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
      completed: false,
      text
    }, 
    ...state
  ],

  [DELETE_TODO]: (state, {id}) => state.filter(todo => todo.id !== id),


  [EDIT_TODO]: (state, {id, text}) => 
    state.map(todo => (todo.id === id) ? {...todo, text} : todo),

  [COMPLETE_TODO]: (state, {id}) => 
    state.map(todo => (todo.id === id) ? { ...todo, completed: !todo.completed } : todo),

  [COMPLETE_ALL]: state => {
      const areAllMarked = state.every(todo => todo.completed);
      return state.map(todo => ({ ...todo, completed: !areAllMarked }))
  },

  [CLEAR_COMPLETED]: state => state.filter(todo => todo.completed === false)
}

Pros

  1. Parameter destructuring e.g:
(state, {id, text}) => 
    state.map(todo => (todo.id === id) ? {...todo, text} : todo)
  1. No "return" statement
  2. No "case" statement
  3. Less code
  4. Better readability

And here function to convert declarative reducer to standard reducer:

function createReducer(reducerConfig){
  const { initialState, ...reducers } = reducerConfig;

  return (state = initialState, action) => {
    const reducer = reducers[action.type];

    if (typeof reducer === 'function'){
      return reducer(state, action);
    } else {
      return state;
    }
  };
}
@simplesmiler

This comment has been minimized.

Show comment
Hide comment
@simplesmiler

simplesmiler Nov 12, 2015

As far as I can say, similar syntax has been proposed multiple times, and even mentioned in the docs.

Generally speaking, reducer does not have to map <action type, function> one-to-one. It is more like <predicate, function>. There are valid use cases where reducer checks the presence of specific payload in the action instead of checking action type.

But nothing stops you from using this syntax in your reducers, especially given the fact how less code this requires you to implement.

As far as I can say, similar syntax has been proposed multiple times, and even mentioned in the docs.

Generally speaking, reducer does not have to map <action type, function> one-to-one. It is more like <predicate, function>. There are valid use cases where reducer checks the presence of specific payload in the action instead of checking action type.

But nothing stops you from using this syntax in your reducers, especially given the fact how less code this requires you to implement.

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

From http://redux.js.org/docs/recipes/ReducingBoilerplate.html#generating-reducers:

Let’s write a function that lets us express reducers as an object mapping from action types to handlers. For example, if we want our todos reducers to be defined like this:

export const todos = createReducer([], {
  [ActionTypes.ADD_TODO](state, action) {
    let text = action.text.trim()
    return [ ...state, text ]
  }
})

We can write the following helper to accomplish this:

function createReducer(initialState, handlers) {
  return function reducer(state = initialState, action) {
    if (handlers.hasOwnProperty(action.type)) {
      return handlers[action.type](state, action)
    } else {
      return state
    }
  }
}

This wasn’t difficult, was it? Redux doesn’t provide such a helper function by default because there are many ways to write it. Maybe you want it to automatically convert plain JS objects to Immutable objects to hydrate the server state. Maybe you want to merge the returned state with the current state. There may be different approaches to a “catch all” handler. All of this depends on the conventions you choose for your team on a specific project.

The Redux reducer API is (state, action) => state, but how you create those reducers is up to you.

Collaborator

gaearon commented Nov 12, 2015

From http://redux.js.org/docs/recipes/ReducingBoilerplate.html#generating-reducers:

Let’s write a function that lets us express reducers as an object mapping from action types to handlers. For example, if we want our todos reducers to be defined like this:

export const todos = createReducer([], {
  [ActionTypes.ADD_TODO](state, action) {
    let text = action.text.trim()
    return [ ...state, text ]
  }
})

We can write the following helper to accomplish this:

function createReducer(initialState, handlers) {
  return function reducer(state = initialState, action) {
    if (handlers.hasOwnProperty(action.type)) {
      return handlers[action.type](state, action)
    } else {
      return state
    }
  }
}

This wasn’t difficult, was it? Redux doesn’t provide such a helper function by default because there are many ways to write it. Maybe you want it to automatically convert plain JS objects to Immutable objects to hydrate the server state. Maybe you want to merge the returned state with the current state. There may be different approaches to a “catch all” handler. All of this depends on the conventions you choose for your team on a specific project.

The Redux reducer API is (state, action) => state, but how you create those reducers is up to you.

@gaearon gaearon closed this Nov 12, 2015

@ujeenator

This comment has been minimized.

Show comment
Hide comment
@ujeenator

ujeenator Nov 12, 2015

gaearon, does "redux" package provide "createReducer" function?

Or it need to be copy-pasted every time?

gaearon, does "redux" package provide "createReducer" function?

Or it need to be copy-pasted every time?

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

Reducer composition is a common pattern in Redux. See shopping-cart example:

function products(state, action) {
  switch (action.type) {
    case ADD_TO_CART:
      return {
        ...state,
        inventory: state.inventory - 1
      }
    default:
      return state
  }
}

function byId(state = {}, action) {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product
          return obj
        }, {})
      }
    default: // <------------- how do you express this with a function map?
      const { productId } = action // <------------- predicate by action field, not by action type!
      if (productId) {
        return {
          ...state,
          [productId]: products(state[productId], action) // <------------- or this call?
        }
      }
      return state
  }
}

function visibleIds(state = [], action) {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return action.products.map(product => product.id)
    default:
      return state
  }
}

export default combineReducers({
  byId,
  visibleIds
}) // <------------- or how do you combine reducers if they're objects?

If we force users to declare reducers as object maps, many powerful patterns that are possible with functions become harder and non-obvious. This is why we don't encourage users to take shortcuts. If you discover a shortcut and find it convenient, use it, but we don't want to limit your imagination because otherwise you won't come up with reducer composition-based solutions like the code above.

Collaborator

gaearon commented Nov 12, 2015

Reducer composition is a common pattern in Redux. See shopping-cart example:

function products(state, action) {
  switch (action.type) {
    case ADD_TO_CART:
      return {
        ...state,
        inventory: state.inventory - 1
      }
    default:
      return state
  }
}

function byId(state = {}, action) {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product
          return obj
        }, {})
      }
    default: // <------------- how do you express this with a function map?
      const { productId } = action // <------------- predicate by action field, not by action type!
      if (productId) {
        return {
          ...state,
          [productId]: products(state[productId], action) // <------------- or this call?
        }
      }
      return state
  }
}

function visibleIds(state = [], action) {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return action.products.map(product => product.id)
    default:
      return state
  }
}

export default combineReducers({
  byId,
  visibleIds
}) // <------------- or how do you combine reducers if they're objects?

If we force users to declare reducers as object maps, many powerful patterns that are possible with functions become harder and non-obvious. This is why we don't encourage users to take shortcuts. If you discover a shortcut and find it convenient, use it, but we don't want to limit your imagination because otherwise you won't come up with reducer composition-based solutions like the code above.

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

does "redux" package provide "createReducer" function?

It doesn't. But createReducer doesn't need to be copy-pasted either. Create a package with it, if you like it. We don't want to support this as an official solution because we don't believe such indirection is necessary or even desirable. This is in the realm of projects like https://github.com/gajus/canonical-reducer-composition that want to dictate their specific conventions. Redux has no opinion here.

Collaborator

gaearon commented Nov 12, 2015

does "redux" package provide "createReducer" function?

It doesn't. But createReducer doesn't need to be copy-pasted either. Create a package with it, if you like it. We don't want to support this as an official solution because we don't believe such indirection is necessary or even desirable. This is in the realm of projects like https://github.com/gajus/canonical-reducer-composition that want to dictate their specific conventions. Redux has no opinion here.

@ujeenator

This comment has been minimized.

Show comment
Hide comment
@ujeenator

ujeenator Nov 12, 2015

many powerful patterns that are possible with functions become harder and non-obvious.

May you give an example of such pattern?

many powerful patterns that are possible with functions become harder and non-obvious.

May you give an example of such pattern?

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

I added comments to the example.

Collaborator

gaearon commented Nov 12, 2015

I added comments to the example.

@gaearon gaearon added the discussion label Nov 12, 2015

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

Same discussion in the past: https://github.com/rackt/redux/issues/883.

Collaborator

gaearon commented Nov 12, 2015

Same discussion in the past: https://github.com/rackt/redux/issues/883.

@ujeenator

This comment has been minimized.

Show comment
Hide comment
@ujeenator

ujeenator Nov 12, 2015

@gaearon

Few general questions:

  1. Does redux "core" projects tends to stay "low level"? I mean give more control, than give abstraction layer?
  2. If so - what kind of contribution can I provide? Performance optimization, bug fixes and docs?
  3. Where can I share info about my packages for redux? Is there a wiki page with a list of redux related components?

@gaearon

Few general questions:

  1. Does redux "core" projects tends to stay "low level"? I mean give more control, than give abstraction layer?
  2. If so - what kind of contribution can I provide? Performance optimization, bug fixes and docs?
  3. Where can I share info about my packages for redux? Is there a wiki page with a list of redux related components?
@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

Does redux "core" projects tends to stay "low level"? I mean give more control, than give abstraction layer?

There's just one core project—Redux itself. Yes, it's lower level. It's opinionated where it really matters (e.g. plain object actions, pure reducers), but other than that, you're free to implement your own conventions.

If so - what kind of contribution can I provide? Performance optimization, bug fixes and docs?

I wouldn't expect to find bugs in Redux itself because it's very small and has been thoroughly tested (we barely change the code these days). Generally it's a good idea to watch out for open https://github.com/rackt/redux/issues and offer help there if you think you can spare some.

Collaborator

gaearon commented Nov 12, 2015

Does redux "core" projects tends to stay "low level"? I mean give more control, than give abstraction layer?

There's just one core project—Redux itself. Yes, it's lower level. It's opinionated where it really matters (e.g. plain object actions, pure reducers), but other than that, you're free to implement your own conventions.

If so - what kind of contribution can I provide? Performance optimization, bug fixes and docs?

I wouldn't expect to find bugs in Redux itself because it's very small and has been thoroughly tested (we barely change the code these days). Generally it's a good idea to watch out for open https://github.com/rackt/redux/issues and offer help there if you think you can spare some.

@omnidan

This comment has been minimized.

Show comment
Hide comment
@omnidan

omnidan Nov 12, 2015

Collaborator

Where can I share info about my packages for redux? Is there a wiki page with a list of redux related components?

Have a look at awesome-redux and the Ecosystem page in the docs.

Collaborator

omnidan commented Nov 12, 2015

Where can I share info about my packages for redux? Is there a wiki page with a list of redux related components?

Have a look at awesome-redux and the Ecosystem page in the docs.

@dzannotti

This comment has been minimized.

Show comment
Hide comment
@dzannotti

dzannotti Nov 12, 2015

Contributor

Isn't this what https://github.com/acdlite/redux-actions does anyway? I don't think it's the switch statement in itself but rather the semantics of it in js that makes it unappealing (and probably the need to import the constant too)

Contributor

dzannotti commented Nov 12, 2015

Isn't this what https://github.com/acdlite/redux-actions does anyway? I don't think it's the switch statement in itself but rather the semantics of it in js that makes it unappealing (and probably the need to import the constant too)

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

(and probably the need to import the constant too)

Constants are just strings. Those who don't like constants are free to use strings directly..

Collaborator

gaearon commented Nov 12, 2015

(and probably the need to import the constant too)

Constants are just strings. Those who don't like constants are free to use strings directly..

@omnidan

This comment has been minimized.

Show comment
Hide comment
@omnidan

omnidan Nov 12, 2015

Collaborator

@dzannotti redux-actions is for action creators, not for reducers 😉

Collaborator

omnidan commented Nov 12, 2015

@dzannotti redux-actions is for action creators, not for reducers 😉

@dzannotti

This comment has been minimized.

Show comment
Hide comment
@dzannotti

dzannotti Nov 12, 2015

Contributor

using string directly leads to inconsistency way too easely

Contributor

dzannotti commented Nov 12, 2015

using string directly leads to inconsistency way too easely

@gaearon

This comment has been minimized.

Show comment
Hide comment
@gaearon

gaearon Nov 12, 2015

Collaborator

@dzannotti

And that's why we propose to use constants 😉

@omnidan

handleAction() generates a reducer.

Collaborator

gaearon commented Nov 12, 2015

@dzannotti

And that's why we propose to use constants 😉

@omnidan

handleAction() generates a reducer.

@dzannotti

This comment has been minimized.

Show comment
Hide comment
@dzannotti

dzannotti Nov 12, 2015

Contributor

@omnidan no it's for both

const reducer = handleActions({
  INCREMENT: (state, action) => ({
    counter: state.counter + action.payload
  }),

  DECREMENT: (state, action) => ({
    counter: state.counter - action.payload
  })
}, { counter: 0 }); 
Contributor

dzannotti commented Nov 12, 2015

@omnidan no it's for both

const reducer = handleActions({
  INCREMENT: (state, action) => ({
    counter: state.counter + action.payload
  }),

  DECREMENT: (state, action) => ({
    counter: state.counter - action.payload
  })
}, { counter: 0 }); 
@omnidan

This comment has been minimized.

Show comment
Hide comment
@omnidan

omnidan Nov 12, 2015

Collaborator

Ah I see 😄 Well, TIL.

Collaborator

omnidan commented Nov 12, 2015

Ah I see 😄 Well, TIL.

@SebastienDaniel

This comment has been minimized.

Show comment
Hide comment
@SebastienDaniel

SebastienDaniel Nov 12, 2015

@dzannotti
Just create middleware to hold and validate all actions in your app. It does the same end-result as constants (consistency, flags undefined, single actions reference (json file)) and eliminates the boilerplate around importing constants all over the place.

Constants vs strings vs middleware, in the end it's really just a question of preference. :)

@dzannotti
Just create middleware to hold and validate all actions in your app. It does the same end-result as constants (consistency, flags undefined, single actions reference (json file)) and eliminates the boilerplate around importing constants all over the place.

Constants vs strings vs middleware, in the end it's really just a question of preference. :)

@dzannotti

This comment has been minimized.

Show comment
Hide comment
@dzannotti

dzannotti Nov 12, 2015

Contributor

@SebastienDaniel yep that's what i'm doing currently actually :)

Contributor

dzannotti commented Nov 12, 2015

@SebastienDaniel yep that's what i'm doing currently actually :)

@hakanderyal

This comment has been minimized.

Show comment
Hide comment
@hakanderyal

hakanderyal Nov 13, 2015

One side effects of using constants and importing them in reducers, is they document which actions the reducers use.

In simple apps, this may not matter much. But for complex apps, more than one, mostly unrelated reducers may need to change the state based on an action.

One side effects of using constants and importing them in reducers, is they document which actions the reducers use.

In simple apps, this may not matter much. But for complex apps, more than one, mostly unrelated reducers may need to change the state based on an action.

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