/extensible-duck

Modular and Extensible Redux Reducer Bundles (ducks-modular-redux)

Primary LanguageJavaScriptMIT LicenseMIT

extensible-duck

extensible-duck is an implementation of the Ducks proposal. With this library you can create reusable and extensible ducks.

Travis build status Code Climate Test Coverage Dependency Status devDependency Status

Basic Usage

// widgetsDuck.js

import Duck from 'extensible-duck'

export default new Duck({
  namespace: 'my-app', store: 'widgets',
  types: ['LOAD', 'CREATE', 'UPDATE', 'REMOVE'],
  initialState: {},
  reducer: (state, action, duck) => {
    switch(action.type) {
      // do reducer stuff
      default: return state
    }
  },
  selectors: {
    root: state => state
  },
  creators: (duck) => ({
    loadWidgets:      () => ({ type: duck.types.LOAD }),
    createWidget: widget => ({ type: duck.types.CREATE, widget }),
    updateWidget: widget => ({ type: duck.types.UPDATE, widget }),
    removeWidget: widget => ({ type: duck.types.REMOVE, widget })
  })
})
// reducers.js

import { combineReducers } from 'redux'
import widgetDuck from './widgetDuck'

export default combineReducers({ [widgetDuck.store]: widgetDuck.reducer })

Constructor Arguments

const { namespace, store, types, consts, initialState, creators } = options

Name Description Type Example
namespace Used as a prefix for the types String 'my-app'
store Used as a prefix for the types and as a redux state key String 'widgets'
storePath Object path of the store from root infinal redux state. Defaults to the [duck.store] value. Can be used to define duck store location in nested state String 'foo.bar'
types List of action types Array [ 'CREATE', 'UPDATE' ]
consts Constants you may need to declare Object of Arrays { statuses: [ 'LOADING', 'LOADED' ] }
initialState State passed to the reducer when the state is undefined Anything {}
reducer Action reducer function(state, action, duck) (state, action, duck) => { return state }
creators Action creators function(duck) duck => ({ type: types.CREATE })
sagas Action sagas function(duck) duck => ({ fetchData: function* { yield ... }
takes Action takes function(duck) duck => ([ takeEvery(types.FETCH, sagas.fetchData) ])
selectors state selectors Object of functions
or
function(duck)
{ root: state => state}
or
duck => ({ root: state => state })

Duck Accessors

  • duck.store
  • duck.storePath
  • duck.reducer
  • duck.creators
  • duck.sagas
  • duck.takes
  • duck.selectors
  • duck.types
  • for each const, duck.<const>

Helper functions

  • constructLocalized(selectors): maps selectors syntax from (globalStore) => selectorBody into (localStore, globalStore) => selectorBody. localStore is derived from globalStore on every selector execution using duck.storage key. Use to simplify selectors syntax when used in tandem with reduxes' combineReducers to bind the duck to a dedicated state part (example). If defined will use the duck.storePath value to determine the localized state in deeply nested redux state trees.

Defining the Reducer

While a plain vanilla reducer would be defined by something like this:

function reducer(state={}, action) {
  switch (action.type) {
    // ...
    default:
      return state
  }
}

Here the reducer has two slight differences:

new Duck({
  // ...
  reducer: (state, action, duck) => {
    switch (action.type) {
      // ...
      default:
        return state
    }
  }
})

With the duck argument you can access the types, the constants, etc (see Duck Accessors).

Defining the Creators

While plain vanilla creators would be defined by something like this:

export function createWidget(widget) {
  return { type: CREATE, widget }
}

// Using thunk
export function updateWidget(widget) {
  return dispatch => {
    dispatch({ type: UPDATE, widget })
  }
}

With extensible-duck you define it as an Object of functions:

export default new Duck({
  // ...
  creators: {
    createWidget: widget => ({ type: 'CREATE', widget })

    // Using thunk
    updateWidget: widget => dispatch => {
      dispatch({ type: 'UPDATE', widget })
    }
  }
})

If you need to access any duck attribute, you can define a function that returns the Object of functions:

export default new Duck({
  // ...
  types: [ 'CREATE' ],
  creators: (duck) => ({
    createWidget: widget => ({ type: duck.types.CREATE, widget })
  })
})

Defining the Sagas

While plain vanilla creators would be defined by something like this:

function* fetchData() {
  try{
  	yield put({ type: reducerDuck.types.FETCH_PENDING })
    const payload = yield call(Get, 'data')
    yield put({
      type: reducerDuck.types.FETCH_FULFILLED,
      payload
    })
  } catch(err) {
    yield put({
      type: reducerDuck.types.FETCH_FAILURE,
      err
    })
  }
}

// Defining observer
export default [ takeEvery(reducerDuck.types.FETCH, fetchData) ]

With extensible-duck you define it as an Object of functions accessing any duck attribute:

export default new Duck({
  // ...
  sagas: {
    fetchData: function* (duck) {
    	try{
        yield put({ type: duck.types.FETCH_PENDING })
        const payload = yield call(Get, 'data')
        yield put({
          type: duck.types.FETCH_FULFILLED,
          payload
        })
      } catch(err) {
        yield put({
          type: duck.types.FETCH_FAILURE,
          err
        })
      }
    }
  },
  // Defining observer
  takes: (duck) => ([
  	takeEvery(duck.types.FETCH, duck.sagas.fetchData)
  ])
})

Defining the Initial State

Usually the initial state is declared within the the reducer declaration, just like bellow:

function myReducer(state = {someDefaultValue}, action) {
  // ...
}

With extensible-duck you define it separately:

export default new Duck({
  // ...
  initialState: {someDefaultValue}
})

If you need to access the types or constants, you can define this way:

export default new Duck({
  // ...
  consts: { statuses: ['NEW'] },
  initialState: ({ statuses }) => ({ status: statuses.NEW })
})

Defining the Selectors

Simple selectors:

export default new Duck({
  // ...
  selectors: {
    shopItems:  state => state.shop.items
  }
})

Composed selectors:

export default new Duck({
  // ...
  selectors: {
    shopItems:  state => state.shop.items,
    subtotal: new Duck.Selector(selectors => state =>
      selectors.shopItems(state).reduce((acc, item) => acc + item.value, 0)
    )
  }
})

Using with Reselect:

export default new Duck({
  // ...
  selectors: {
    shopItems:  state => state.shop.items,
    subtotal: new Duck.Selector(selectors =>
      createSelector(
        selectors.shopItems,
        items => items.reduce((acc, item) => acc + item.value, 0)
      )
    )
  }
})

Selectors with duck reference:

export default new Duck({
  // ...
  selectors: (duck) => ({
    shopItems:  state => state.shop.items,
    addedItems: new Duck.Selector(selectors =>
      createSelector(
        selectors.shopItems,
        items => {
          const out = [];
          items.forEach(item => {
            if (-1 === duck.initialState.shop.items.indexOf(item)) {
              out.push(item);
            }
          });
          return out;
        }
      )
    )
  })
})

Defining the Types

export default new Duck({
  namespace: 'my-app', store: 'widgets',
  // ...
  types: [
    'CREATE',   // myDuck.types.CREATE   = "my-app/widgets/CREATE"
    'RETREIVE', // myDuck.types.RETREIVE = "my-app/widgets/RETREIVE"
    'UPDATE',   // myDuck.types.UPDATE   = "my-app/widgets/UPDATE"
    'DELETE',   // myDuck.types.DELETE   = "my-app/widgets/DELETE"
  ]
}

Defining the Constants

export default new Duck({
  // ...
  consts: {
    statuses: ['NEW'], // myDuck.statuses = { NEW: "NEW" }
    fooBar: [
      'FOO',           // myDuck.fooBar.FOO = "FOO"
      'BAR'            // myDuck.fooBar.BAR = "BAR"
    ]
  }
}

Creating Reusable Ducks

This example uses redux-promise-middleware and axios.

// remoteObjDuck.js

import Duck from 'extensible-duck'
import axios from 'axios'

export default function createDuck({ namespace, store, path, initialState={} }) {
  return new Duck({
    namespace, store,

    consts: { statuses: [ 'NEW', 'LOADING', 'READY', 'SAVING', 'SAVED' ] },

    types: [
      'UPDATE',
      'FETCH', 'FETCH_PENDING',  'FETCH_FULFILLED',
      'POST',  'POST_PENDING',   'POST_FULFILLED',
    ],

    reducer: (state, action, { types, statuses, initialState }) => {
      switch(action.type) {
        case types.UPDATE:
          return { ...state, obj: { ...state.obj, ...action.payload } }
        case types.FETCH_PENDING:
          return { ...state, status: statuses.LOADING }
        case types.FETCH_FULFILLED:
          return { ...state, obj: action.payload.data, status: statuses.READY }
        case types.POST_PENDING:
        case types.PATCH_PENDING:
          return { ...state, status: statuses.SAVING }
        case types.POST_FULFILLED:
        case types.PATCH_FULFILLED:
          return { ...state, status: statuses.SAVED }
        default:
          return state
      }
    },

    creators: ({ types }) => ({
      update: (fields) => ({ type: types.UPDATE, payload: fields }),
      get:        (id) => ({ type: types.FETCH, payload: axios.get(`${path}/${id}`),
      post:         () => ({ type: types.POST, payload: axios.post(path, obj) }),
      patch:        () => ({ type: types.PATCH, payload: axios.patch(`${path}/${id}`, obj) })
    }),

    initialState: ({ statuses }) => ({ obj: initialState || {}, status: statuses.NEW, entities: [] })
  })
}
// usersDuck.js

import createDuck from './remoteObjDuck'

export default createDuck({ namespace: 'my-app', store: 'user', path: '/users' })
// reducers.js

import { combineReducers } from 'redux'
import userDuck from './userDuck'

export default combineReducers({ [userDuck.store]: userDuck.reducer })

Extending Ducks

This example is based on the previous one.

// usersDuck.js

import createDuck from './remoteObjDuck.js'

export default createDuck({ namespace: 'my-app',store: 'user', path: '/users' }).extend({
  types: [ 'RESET' ],
  reducer: (state, action, { types, statuses, initialState }) => {
    switch(action.type) {
      case types.RESET:
        return { ...initialState, obj: { ...initialState.obj, ...action.payload } }
      default:
        return state
  },
  creators: ({ types }) => ({
    reset: (fields) => ({ type: types.RESET, payload: fields }),
  })
})

Creating Reusable Duck Extensions

This example is a refactor of the previous one.

// resetDuckExtension.js

export default {
  types: [ 'RESET' ],
  reducer: (state, action, { types, statuses, initialState }) => {
    switch(action.type) {
      case types.RESET:
        return { ...initialState, obj: { ...initialState.obj, ...action.payload } }
      default:
        return state
  },
  creators: ({ types }) => ({
    reset: (fields) => ({ type: types.RESET, payload: fields }),
  })
}
// userDuck.js

import createDuck from './remoteObjDuck'
import reset from './resetDuckExtension'

export default createDuck({ namespace: 'my-app',store: 'user', path: '/users' }).extend(reset)

Creating Ducks with selectors

Selectors help in providing performance optimisations when used with libraries such as React-Redux, Preact-Redux etc.

// Duck.js

import Duck, { constructLocalized } from 'extensible-duck'

export default new Duck({
  store: 'fruits',
  initialState: {
    items: [
      { name: 'apple', value: 1.2 },
      { name: 'orange', value: 0.95 }
    ]
  },
  reducer: (state, action, duck) => {
    switch(action.type) {
      // do reducer stuff
      default: return state
    }
  },
  selectors: constructLocalized({
    items: state => state.items, // gets the items from state
    subTotal: new Duck.Selector(selectors => state =>
      // Get another derived state reusing previous selector. In this case items selector
      // Can compose multiple such selectors if using library like reselect. Recommended!
      // Note: The order of the selectors definitions matters
      selectors
        .items(state)
        .reduce((computedTotal, item) => computedTotal + item.value, 0)
    )
  })
})
// reducers.js

import { combineReducers } from 'redux'
import Duck from './Duck'

export default combineReducers({ [Duck.store]: Duck.reducer })
// HomeView.js
import React from 'react'
import Duck from './Duck'

@connect(state => ({
  items: Duck.selectors.items(state),
  subTotal: Duck.selectors.subTotal(state)
}))
export default class HomeView extends React.Component {
  render(){
    // make use of sliced state here in props
    ...
  }
}