ashtonsix / redux-plus

5 stars 0 forks source link

redux-plus

The core of Redux is simple. But it comes with a big ecosystem, middleware, action creators, selectors and other things attached that slow down development - a single change in specs shouldn't require changes in 5+ locations to implement. redux-plus makes developers more productive by finding one place for all state-related code: the reducer.

redux-plus makes four non-breaking changes to redux:

dispatchEnhancer: When using redux-plus you should dispatch actions directly to the store using store.dispatch. The API is nicer now: store.dispatch('INCREMENT', 5) & store.dispatch({type: 'INCREMENT', payload: 5}) are equivalent.

effectEnhancer: Running side-effects inside the reducer is bad because it makes your state hard to predict & test. With redux-plus you can return effects from the reducer which run in a different context. Effects can optionally return actions that are dispatched to the store.

selectorEnhancer: Selectors are like formulas in a spreadsheet. They compute derived data and only update when the data they depend on does.

dynamicReducerEnhancer: Some (mostly performance-related) problems are impossible to solve statically. redux-plus allows you to generate reducers on the fly. This feature is very powerful and should be used with caution and you probably won't need it.

Usage

import {
  createStore, combineReducers, createReducer,
  createEffect, createSelector} from 'redux-plus'

const reducer = combineReducers({
  counter: createReducer({
    INCREMENT: state => state + 1,
    INCREMENT_IN_5_SECONDS: state => createEffect(
      state,
      () => new Promise(resolve => setTimeout(() =>
        resolve('INCREMENT'),
        5000))
    ),
  }, 0),
  counterDoubled: createSelector(
    'counter',
    (state, counter) => counter * 2),
})

const store = createStore(reducer)

store.getState() // {counter: 0, counterDoubled: 0}
store.dispatch('INCREMENT')
store.getState() // {counter: 1, counterDoubled: 2}
store.dispatch('INCREMENT_IN_5_SECONDS')
store.getState() // {counter: 1, counterDoubled: 2}
setTimeout(() =>
  store.getState(), // {counter: 2, counterDoubled: 4}
  5001)

API

createStore(reducer, [initialState], [storeEnhancer] = plus)

Drop-in replacement for redux.createStore. Uses plus store-enhancer by default

import {createStore, plus, compose} from 'redux-plus'

const store = createStore(reducer) // use it like this
const store = createStore(
  reducer,
  compose(plus, window.devToolsExtension ? window.devToolsExtension() : f => f),
) // or with other store enhancers
plus

The composite store-enhancer, you can import individual enhancers from redux-plus/modules/enhancers

import {dispatchEnhancer, selectorEnhancer, effectEnhancer} from 'redux-plus/modules/enhancers'

export const plus = compose(
  dispatchEnhancer,
  selectorEnhancer, // doubles up as dynamicReducerEnhancer
  effectEnhancer,
)
createReducer(reducerMap, [initialState])

createReducer is convenience-only, write reducers as plain functions if you like

Maps action types to reducers

const counter = createReducer({
  INCREMENT: (state, {payload = 1}) => state + payload,
  DECREMENT: (state, {payload = 1}) => state - payload,
}, 0)

counter(undefined, {type: 'INCREMENT'}) // 1
counter(5, {type: 'INCREMENT'}) // 6
counter(82, {type: 'DECREMENT', payload: 80}) // 2
counter(13, {type: 'SOME_OTHER_ACTION'}) // 13

A switch statement could do the same thing

const counter = (state = 0, {type, payload}) => {
  switch (type) {
    case 'INCREMENT': return state + payload
    case 'DECREMENT': return state - payload
    default: return state
  }
}
combineReducers(reducerMap, rootState, {getter, setter})

Use combineReducers to split up your state

const reducer = combineReducers({
  todos: createReducer({
    ADD_TODO: (state, {payload}) => state.concat(payload)
  }),
  counter,
})

reducer(undefined, {type: 'ADD_TODO', payload: 'Read the docs'})
// {todos: ['Read the docs'], counter: 0}

You probably don't need to worry about this but combineReducers also lifts effects

const newState = {
  todos: [[{id: 1, name: 'Read the docs'}], [() => getTodo(2)], isEffect: true],
  user: [{}, [() => login('ashtonwar', 'password')], isEffect: true],
}

liftEffects(newState)
// [{todos: [{id: 1, name: 'Read the docs'}], user: {}},
//  [() => getTodo(2), () => login('ashtonwar', 'password')],
//  isEffect: true]

And adds structural metadata to the reducer so the store-enhancer can build dependency trees

The rootState and getter / setter arguments help support a variety of reducer shapes like arrays

const reducer = combineReducers([todos, counter], [])

reducer(undefined, {type: 'ADD_TODO', payload: 'Read the docs'})
// [['Read the docs'], 0]

Or Immutable.js maps

const reducer = combineReducers(
  {
    todos: createReducer({
      ADD_TODO: (state, {payload}) => state.push(payload),
    }, Immutable.List()),
    counter
  },
  Immutable.Map(),
)

reducer(undefined, {type: 'ADD_TODO', payload: 'Read the docs'}).toJS()
// {todos: ['Read the docs'], counter: 0}
createEffect(newState, ...generators)

The state returned by the reducer may contain descriptions of side-effects (generators), that the effectEnhancer knows how to interpret. Calling the reducer does not run effects

const clock = createReducer({
  TICK: state => createEffect(!state, 'TICK'),
}, false)

const store = createStore(clock)
store.dispatch('TICK') // start it off
store.subscribe(::console.log) // true, false, true... until universe heat death

An effect may contain multiple generators. Generators may be constants, functions or functions that return promises. They can optionally return actions that will be dispatched to the store

const reducer = createReducer({
  ACTION_1: () => createEffect(
    null,
    'ACTION_2', // these
    () => 'ACTION_3', // are all
    () => Promise.resolve('ACTION_4'), // equivalent
    () => {}, // this won't dispatch anything
  ),
})
const logger = applyMiddleware(store => next => action => (console.log(action), next(action)))
const store = createStore(reducer, compose(plus, logger))
store.dispatch('ACTION_1')
// {type: 'ACTION_1'}
// {type: 'ACTION_2'}
// {type: 'ACTION_3'}
// {type: 'ACTION_4'}

Effects are useful for things like making HTTP requests and interacting with browser APIs

createSelector(...dependencies, reducer)

Selectors are like formulas in a spreadsheet. They compute derived data and only update when the data they depend on does

Each dependency is a path relevant to the state's root. These will be resolved and passed to the reducer when evaluated by the selectorEnhancer

const reducer = combineReducers({
  todos: createReducer({
    ADD_TODO: (state, {payload}) => state.concat(payload),
  }, []),
  searchQuery: createReducer({
    UPDATE_SEARCH: (state, {payload}) => payload,
  }, ''),
  searchResults: createSelector(
    'todos',
    'searchQuery',
    (state, todos, searchQuery) =>
      todos.filter(todo => todo.indexOf(searchQuery) !== -1)
  ),
})

const store = createStore(reducer)
store.dispatch('ADD_TODO', 'Wash the dishes')
store.getState().searchResults // ['Wash the dishes']
store.dispatch('ADD_TODO', 'Wash the laundry')
store.dispatch('ADD_TODO', 'Hang the laundry')
store.dispatch('UPDATE_SEARCH', 'laundry')
store.getState().searchResults // ['Wash the laundry', 'Hang the laundry']

Selectors can depend on other selectors & be chained

const reducer = combineReducers({
  counter,
  counterDoubled: createSelector(
    'counter',
    (state, counter) => counter * 2),
  counterHalved: createSelector(
    'counterDoubled',
    (state, counter) => counter / 4),
})

const store = createStore(reducer)

store.getState() // {counter: 0, counterDoubled: 0, counterHalved: 0}
store.dispatch('INCREMENT')
store.getState() // {counter: 1, counterDoubled: 2, counterHalved: 0.5}

Selectors cannot have cyclic dependencies, example: A cannot depend on B, if B relies on A

Selector dependencies can be selectors themselves

const reducer = combineReducers({
  todos: createReducer({
    ADD_TODO: (state, {payload}) => state.concat(payload),
  }, []),
  lastTodo: createSelector(
    ['todos', (state, todos) => `todos.${todos.length - 1}`],
    (state, todo) => todo
  ),
})

const store = createStore(reducer)
store.dispatch('ADD_TODO', 'Wash the dishes')
store.getState().lastTodo // 'Wash the dishes'
store.dispatch('ADD_TODO', 'Wash the laundry')
store.getState().lastTodo // 'Wash the laundry'

Using static dependencies is preferred due to performance penalties (the selector graph has to be rebuilt every time a dynamic dependency is evaluated)

createArraySelector(arrayPointer, itemResolver, [dependencies], reducer, [rootState])

See effcient lists

createDynamicReducer(reducer)

This feature is very powerful and should be used with caution and you probably won't need it.

Dynamic reducers return reducers which are evaluated in-place by the dynamicStoreEnhancer. They are required for problems that cannot be solved statically

Dynamic selector dependencies are a piece of syntactic sugar that relies on dynamic reducers

createSelector(
  ['todos', (state, todos) => `todos.${todos.length - 1}`],
  (state, todo) => todo)

// is sugar for:

createDynamicReducer(
  createSelector(
    'todos',
    (previousReducer, todos) => createSelector(
      `todos.${todos.length - 1}`,
      (state, todo) => todo,
    )))

createArraySelector also relies on dynamic reducers. The reducer creates a selector for each item in the array and combines them into a reducer that only updates the individual items that change.

Dynamic reducers are a potential performance bottleneck, every time one is evaluated the selector graph has to be rebuilt: an O(# selectors # dynamic reducers # actions dispatched) problem

applyMiddleware(middleware)

Same as redux.applyMiddleware

See emulating middleware to learn how middleware can run in the reducer

compose(...storeEnhancers)

Same as redux.compose

getModel(effect) / getGenerators(effect)

These helpers are useful for unit testing reducers. Effects bubble so your final state is likely to be an effect itself

[state, [generator1, generator2], isEffect: true]

getModel & getGenerators return the respective elements and default to state or [] if the state is not an effect.

Tell Me More

Thanks

Others did the real legwork. This library was inspired by: