reduxjs / redux

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

Proposal: declarative reducers #1024

Closed ujeenator closed 8 years ago

ujeenator commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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.

ujeenator commented 8 years ago

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

Or it need to be copy-pasted every time?

gaearon commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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

May you give an example of such pattern?

gaearon commented 8 years ago

I added comments to the example.

gaearon commented 8 years ago

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

ujeenator commented 8 years ago

@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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

(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 commented 8 years ago

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

dzannotti commented 8 years ago

using string directly leads to inconsistency way too easely

gaearon commented 8 years ago

@dzannotti

And that's why we propose to use constants :wink:

@omnidan

handleAction() generates a reducer.

dzannotti commented 8 years ago

@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 commented 8 years ago

Ah I see :smile: Well, TIL.

SebastienDaniel commented 8 years ago

@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 commented 8 years ago

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

hakanderyal commented 8 years ago

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.