1.0.0 • Published 3 years ago

@labelinsight/redux-module v1.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
3 years ago

ReduxModule

The ultimate Redux boilerplate reducer

// this creates an action type, action creator, AND reducer handle
app.create('setUser', ['user'])

Table of Contents

Installation

npm install --save @labelinsight/redux-module
```(

```js
// module
import ReduxModule from '@labelinsight/redux-module'

// commonjs
const ReduxModule = require('@labelinsight/redux-module')

// browser
window.ReduxModule

Why?

redux-module reduces boilerplate noise by removing the need for developers to have to create types or wire them to creators or reducer handlers, ever. The module also provides a convenient setter syntax for automatically creating state merges when updates do not require previous state or logic, which cuts out a surprising amount of code. It has zero dependencies and requires no middleware. It is just a boilerplate ... reducer 😜.

Comparison with Vanilla Redux

const initialState = {
  showLoginForm: false,
  loginPending: false,
  user: null,
}

const SHOW_LOGIN_FORM = 'SHOW_LOGIN_FORM'
const SET_LOGIN_PENDING = 'LOGIN_PENDING'
const SET_USER = 'SET_USER'

export const showLoginForm = (showLoginForm) => ({
  type: SHOW_LOGIN_FORM,
  showLoginForm,
})

export const setLoginPending = (loginPending) => ({
  type: SHOW_LOGIN_FORM,
  loginPending,
})

export const setUser = (user) => ({
  type: SET_USER,
  user,
})

// example assuming redux thunk
export const login = (email) => async (dispatch) => {
  // fetch user, etc.
}

// even before redux-module we preferred a handlers map to a switch case
const handlers = {}

handlers[SHOW_LOGIN_FORM] = (state, { showLoginForm }) => ({
  ...state,
  showLoginForm,
})

handlers[SET_LOGIN_PENDING] = (state, { loginPending }) => ({
  ...state,
  loginPending,
})

handlers[SET_USER] = (state, { user }) => ({
  ...state,
  user,
})

export const reducer = (state = initialState, action) =>
  handlers[action.type] ? handlers[action.type](state, action) : state

With ReduxModule:

import ReduxModule from '@labelinsight/redux-module'

const app = new ReduxModule('app')

const initialState = {
  showLoginForm: false,
  loginPending: false,
  user: null,
}

app.create('showLoginForm', ['showLoginForm'])
app.create('setLoginPending', ['loginPending'])
app.create('setUser', ['user'])

export const getUser = (email) => async (dispatch) => {
  // ...
}

export const reducer = app.reducer(initialState)
export default app

Hopefully you are sold by this minimal example, but if not, consider this: a typical redux "round trip" involves three JavaScript expressions: an action type, a creator, and a reducer case. ReduxModule reduces that to one. So, if your slice of redux has say, 30 of these routines, that means you'll need to write 90 expresions. With redux-module you'd write 30. Furthermore in vanilla redux, assuming basic example above that just sets a top-level key and uses prettier formatting, each "round trip" equates to 9 lines of code. 90 * 9 = 810 lines of code! With redux-module, again, it's just 30 :).

The image below is a real before & after diff of refactoring one of our projects to use redux-module:

before-after

Usage

First, create an instance:

const app = new ReduxModule('app')

The instance returned will have the following structure:

{
  // you _may_ need this if working with something like redux-saga,
  // otherwise it is just an internal storage mechanism for the module instance
  types: {},

  creators: {},

  // you will 99% never need to work with this
  handlers: [],

  // you will use this to create new type/action/reducer combos
  create: Function,

  // you will only use this to create a submodule of a existing instance
  module: Function
}

The heart of redux-module is the #create method. To create an action that sets a simple value on a top-level key of state, you can use setter shorthand syntax:

app.create('setColor', ['color'])

Those brackets instruct the instance to set the color property on state when the setColor action is dispatched. It is the same as manually doing this:

// NOTE: the code here is just to illustrate how types, creators, and handlers are
// stored internally! You should not use the module this way!
app.types.setColor = 'app/setColor'

app.creators.setColor = (color) => ({
  type: 'app/setColor',
  color,
})

app.handlers['app/setColor'] = (state, { color }) => ({
  ...state,
  color,
})

To dispatch the setColor action:

dispatch(app.creators.setColor('rebeccapurple'))

"Setter shorthand" allows for multiple values

store.getState() // => { user: {} }

app.create('setFooAndBar', ['foo', 'bar'])

dispatch(app.creators.setFooAndBar(10, 11))
store.getState() // => { user: {}, foo: 10, bar: 11 }

For traditional reducer logic, you can provide a reducer case as the last argument. It has the same signature as a redux reducer.

app.create('loginFailed', 'status', (state, { status }) => ({
  ...state,
  // some merge logic
}))

Note how the above status argument definition is not wrapped in braces. You cannot mix this signature with setter shortand syntax.

The final step is to create a reducer. Since we already defined our reducer handles directly via calls to create, the only thing left to do is call app.reducer and send the result to Redux' createStore:

// app.js
// ...
const initialState = { user: null, alert: { ... } }

export const reducer = app.reducer(initialState)

// store.js
// ...
import { reducer as app } from './app'

const store = createStore({ app }, ...)

API

ReduxModule

The default export.

new ReduxModule(name: string): => Object

Properties:

  • types: synchronous action creator type defs
  • creators: all action creators created with create
  • handlers: all reducer handlers created in create.
  • name: the ReduxModule instance name as passed to the constructor.

create

Register a type, action creator, and optional reducer handle. Take care to memorize the different signatures this method takes.

Signatures:

(type: string): ActionCreator

Creates an action creator with no payload.

(type: string, ...actionArgs: Array<string>): ActionCreator

Redux action creator

const app = new ReduxModule('app')
app.create('foo', 'a', 'b')
app.creators.foo(1, 2) // => { type: 'app/foo', a: 1, b: 2 }

(type: string, setter: Array<string>): ActionCreator

Redux action creator and a reducer using "setter shorthand".

const app = new ReduxModule('app')

app.create('foo', 'a', 'b')
app.create('bar', ['a', 'b'])

const reducer = app.reducer()

const state = { a: 98, b: 99 }

reducer(state, app.creators.foo(1, 2)) // => { a: 98, b: 99 }
reducer(state, app.creators.bar(1, 2)) // => { a: 1, b: 2 }

(type: string, ...args?: Array<string>, reducerHandle: Function)

If the last argument to create is a function, it will be registered as a reducer "case" for the action type specified by the type argument.

const app = new ReduxModule('app')

app.create('setEmail', 'email', (state, { email }) => ({
  ...state,
  user: {
    ...state.user,
    email,
  },
}))

const state = {
  user: {
    email: undefined,
  },
}

const reducer = app.reducer()

reducer(state, setEmail('example@domain.com'))
// => { user: { email: 'example@domain.com' } }

#module(name: string, ?options: Object)

Create a sub-module of a ReduxModule instance. A submodule will inherit the parent module options and prefix the parent module name with its own name:

const app = new ReduxModule('app')
const sub = app.module('sub')

const feature = sub.module('feature')
feature.create('hello')
feature.types.hello === 'app/sub/feature/hello'

#reducer(initialState?: Object)

Create a reducer which will utilize all handlers defined from previous create calls.

Suggested Usage Patterns

Export Types and Creators

One of the benefits, or caveats, depending on your coding style, is that types and creators are attached to a module instance. I personally find this super convenient and there are some patterns that help.

// app.js
const reducer = app.reducer()
const { types, creators } = app

export { types, creators, reducer }
export default app

Then, when mapping:

import { creators } from './app'

// if you need them all
const mapDispatchToProps = creators

// or just provide the ones this container needs:
const mapDispatchToProps = {
  foo: creators.foo,
  bar: creators.bar,
}

At scale this practice of exporting/importing the #creators object leaves a lot less clutter around in your app as you won't be in the business of managing individual creator functions. The greater benefit comes when refactoring. In vanilla redux, imagine we have an action type called OPEN_GROUP, and we want to rename it to OPEN_PROJECT. You'd have to rename the following:

  • left hand side of type declaration
  • right hand side of type declaration
  • export name of action creator
  • type inclusion on action creator
  • reducer handle key (or case condition)
  • import name in all Container files
  • mapping reference in all Container files

That's a minimum of 7 renames that can't be done with a simple regexp since your types and creators will have completely different casing.

With redux-module, using the export { creators } / import { creators } pattern, this becomes:

  • type name
  • mapping reference in all Container files

And the beauty of this is you can do it with a single find/replace since your types and creators will have the same name, er, that is if you stick with the default style used in the examples.

If you would still prefer to use individual exports, you can export the return value from #create

export const setUser = app.create('setUser')

...though that is the exact redundancy redux-module aims to avoid

Modules

The usage of app as the module name in all previous examples is not contrived. In general, you should only need to call the ReduxModule constructor once to create a top level module, and create submodules from that instance. You'll want to keep this top-level parent module small, as you'll be importing it into various routes and feature folders to create submodules, and will want your route build chunks to be small. Only use the parent app module for really important things that might be used across the entire app.

Here is an example directory structure following Label Insight's conventions (files not related to omitted for brevity)

/src
  /store
    redux (app = new ReduxModule('app'))
  /routes
    /Products
      redux (products = app.module('products'))
    /Product
      redux (product = app.module('product'))

And looking into redux-logger streams in dev tools, we'll see action dispatches from this app as well as our internal libraries like this:

‣ action auth/success
‣ action featureFlags/setFlags
‣ action app/locationChange
‣ action app/products/getProducts
‣ action app/products/setProducts
‣ action app/locationChange
‣ action app/product/setProduct
‣ action app/product/update

Side Effects

Side effects are not the responsibility of this library (similar to redux itself). There is nothing getting in the way of common side effect solutions offered by redux-thunk or redux-saga. In any case, here are a couple code examples illustrating how these two solutions work along with ReduxModule:

redux-saga

// app.js
import ReduxModule from '@labelinsight/redux-module'

const app = new ReduxModule('app')

app.create('getFoo', 'bar')
app.create('setFoo', ['foo'])

export const reducer = app.reducer()
export default app


// sagas.js
import app from './app'

export default function saga() {
  yield takeEvery(app.types.getFoo, onGetFoo)
}

function* onGetFoo({ bar }) {
  const response = yield call(api.getFoo, bar)
  yield put(app.creators.setFoo(response.foo))
}

redux-thunk

There is absolutely nothing special to do with thunks, since thunks don't make use of action types. One thing you can do to for the sake of code organization is add your thunks to the ReduxModule#creators object so you don't have to worry about managing imports/exports:

// app.js
app.create('setModalOpen', ['modalOpen'])

// our thunk def:
app.creators.getFoo = () => (dispatch) => {
  // implmentation...
}

// SomeContainer.js
import app from './app'

const mapStateToProps = {
  setModalOpen: app.creators.setModalOpen,
  getFoo: app.creators.getFoo,
}

Use Reducer Example

There is nothing preventing you from using redux-module along with React's built in useReducer hook.

import React, { useReducer } from 'react'
import ReduxModule from '@labelinsight/redux-module'

const app = new ReduxModule('app')

app.create('setFoo', ['foo'])

const reducer = app.reducer()
const initialState = { foo: undefined }

export default function MyComponent() {
  const [state, dispatch] = useReducer(reducer, initialState)

  // ...
}

License

MIT

1.0.0

3 years ago

0.0.9

3 years ago

0.0.8

3 years ago

0.0.7

3 years ago

0.0.5

3 years ago

0.0.3

3 years ago

0.0.2

3 years ago

0.0.1

3 years ago