bdn-pocket
Version:
pocket tools for managing redux and redux-saga
673 lines (515 loc) • 16.8 kB
Markdown
- [BDN-POCKET](#bdn-pocket)
- [Deprecated](#deprecated)
- [Why bdn-pocket ?](#why-bdn-pocket)
- [Definitions](#definitions)
- [Project size](#project-size)
- [Principles](#principles)
- [Concepts](#concepts)
- [Action](#action)
- [Signal](#signal)
- [Message](#message)
- [Messenger](#messenger)
- [Usage](#usage)
- [PropTypes](#proptypes)
- [Available types](#available-types)
- [Signal](#signal)
- [Create](#create)
- [Change prefix](#change-prefix)
- [PropTypes](#proptypes)
- [Dispatch](#dispatch)
- [Watch (in saga)](#watch-in-saga)
- [Messenger](#messenger)
- [Create](#create)
- [Combine with [redux] `reducer` (`makeReducer`)](#combine-with-redux-reducer-makereducer)
- [Call message (from saga)](#call-message-from-saga)
- [Path reducer (`makePathReducer`)](#path-reducer-makepathreducer)
- [Selector & SliceSelector](#selector-sliceselector)
# BDN-POCKET
easily manage state management and state selection with [redux] and [redux-saga]
## Deprecated
see: https://www.npmjs.com/package/@b-flower/bdn-pocket
## Why bdn-pocket ?
bdn-pocket is a set of tools that helps to manage state and allow link between [redux] and [redux-saga] more easily.
It brings some new concepts like `Signal`, `Message` and `Messenger` that helps to separate `ActionCreator` in different categories with separation of responsabilities.
It allows `Selector` with `arguments` and a clean memoization (inspired by [reselect] and using it)
It enforces readibility and runtime validation with integration of `propTypes` on `Action` and `Selector`.
bdn-pocket has been built by [b-eden] development team.
This project is an extract of differents concepts and development already existing in [b-eden] project and gathered now in this projet with some enhancements.
bdn-pocket uses [stampit] which brings composition and configuration with ease.
[b-eden] team plans to replace existing [b-eden] code with this library.
You can use this library with small project.
## Definitions
`Action` = `actionCreator` => it generates [redux] `action`s
## Project size
This library is not intended to be used for small projects.
`Action` is powerfull and flexible tools that helps to create `action`s.
`Selector` extends [reselect] with some new features and easy composition.
This library has been built for large projects using [redux] and [redux-saga].
Read [Principles] for explanation.
## Principles
At [b-eden] team we use [redux] with [redux-saga] for more than 2 years and that lead to some principles.
* No side effect in a container (and component of course)
* A container should not dispatch an action that change the state
* All side effects should be done in [redux-saga]
Those principles help [b-eden] dev team to build a robust, maintenable, readable with comprehensive architecture app.
With separation of concern of `Action` between `Signal` and `Message`
## Concepts
### Action
`Action` is the same concept as redux one.
But in [b-eden], `Action` are never used in favor of 2 new concepts (`Signal`, `Messenger`).
In bdn-pocket `Action` is an `action` creator.
* `Action` generates `action`s
* `Signal` generates `signal`s
### Signal
A `Signal` is purely an `Action`, it creates an `action`.
````javascript
import { Signal, Action } from 'bdn-pocket'
console.log(Signal === Action) // => true
````
A `signal` must follow these principles:
* a (dispatched) `signal` will never be used to change the [redux] `state`
* a `container` will always call a `Signal` (not a `Message`)
### Message
A `message` is an `action` called from a `Messenger`.
It is associated to a `reducer`.
In bdn-pocket `message` is just a definition.
Object `Message` does not exists as it is an `Action` in a `Messenger`.
### Messenger
A `Messenger` is a tool that links a `message` defintion (`Action`) with a `reducer` (`state`).
A `message` is an `action` that will be associated with a `reducer` and will produce an new `state`.
A `message` must follows this principle:
* never use a `message` in a [redux] `container`
## Usage
### PropTypes
`Signal` and `Selector` are composed of `PropTypes` (thanks to [stampit]).
Thus you can enforce props (as React propTypes) you receive and ensure you send good ones.
And it offers readibility and some documentation for the same price.
#### Available types
* `number`
* `string`
* `object`
* `func`
* `array`
* `mixed`
````javascript
import { Signal, Types } from 'bdn-pocket'
const {
number,
string,
object,
func,
array,
mixed,
} = Types
const sig = Signal
.propTypes({
a: string, // required string
b: string({ required: false }) // prop `b` not required but if present should not be null or undefined
c: string({ required: false, allowNull: true }) // prop `c` not required and can be null
})
.def('my signal')
sig({ a: 'a', b: 'b', c: 'c' }) // => not throw
sig({ a: 'a', c: null}) // => not throw
sig({ b: 'b'}) // => throw an error
sig({ a: 'a', b: 10, c: null}) // => throw an error
sig({ a: 'a', b: null, c: null}) // => throw an error
````
You can use short notation to define prop types with required string.
````javascript
import { Signal, Types } from 'bdn-pocket'
const {
string,
} = Types
const sig = Signal
.propTypes({
a: string, // required string
b: string, // required string
})
.def('my sig')
// same as
const sig = Signal
.propTypes('a', 'b')
.def('my sig')
````
### Signal
A `Signal` is an action creator that creates a `signal`.
In the concept of `bdn-pocket` a dispatched `signal` should not result as a [redux] `state` change.
It's goal is to be watched by `saga`.
A [redux] `container` must dispatch a `signal`.
#### Create
````javascript
// in /user/signal.js
import { Signal } from 'bdn-pocket'
const triggerLoadUser = Signal.def('trigger load user')
console.log(triggerLoadUser({ userId: 'me' } ))
// => { type: 'my-app/TRIGGER_LOAD_USER', payload: { userId: 'me' }}
````
#### Change prefix
````javascript
// in /user/signal.js
import { Signal } from 'bdn-pocket'
const triggerLoadUser = Signal.prefix('my-plugin').def('trigger load user')
console.log(triggerLoadUser({ userId: 'me' } ))
// => { type: 'my-plugin/TRIGGER_LOAD_USER', payload: { userId: 'me' }}
````
You can use your own `Signal` definition inside a plugin.
````javascript
// in /lib/my_signal.js
import { Signal } from 'bdn-pocket'
export default Signal.prefix('my-plugin')
// in /user/signal.js
import PluginSignal from '/lib/my-signal'
const triggerLoadUser = PluginSignal.def('trigger load user')
console.log(triggerLoadUser({ userId: 'me' } ))
// => { type: 'my-plugin/TRIGGER_LOAD_USER', payload: { userId: 'me' }}
````
#### PropTypes
You can enforce prop types of `signal` to ensure `userId` is present and has good type.
````javascript
// in /user/signal.js
import { Signal, Types } from 'bdn-pocket'
const { string } = Types
const triggerLoadUser = Signal
.propTypes({
userId: string
})
.def('trigger load user')
console.log(triggerLoadUser({ userId: 'me' } ))
// => { type: 'my-plugin/TRIGGER_LOAD_USER', payload: { userId: 'me' }}
// call without userId throw an error
triggerLoadUser({NOUSERID: ''}) // => throw an error
````
#### Dispatch
````javascript
// in /module/user/signal.js
import { Signal } from 'bdn-pocket'
export const triggerLoadUser = Signal.def('trigger load user')
// in /module/user/container
import { connect } from 'react-redux'
import * as userSig from '../signals' // <- signals are here
import MyComp from '../component/my_comp'
export default connect(
null,
function dispatchToProps(dispatch) {
return {
loadUser(userId) {
dispatch(userSig.triggerLoadUser({ userId }) // <- signal accept only one arg
}
}
}
)(MyComp)
````
#### Watch (in saga)
````javascript
// in /module/user/signal.js
import { Signal } from 'bdn-pocket'
export const triggerLoadUser = Signal.def('trigger load user')
// in /module/user/sagas
import { take, call } from 'redux-saga/effects'
import * as userSig from '../signals' // <- signals are here
export function* watchLoadUser() {
while(true) {
const { payload } = yield take(userSig.triggerLoadUser.CONST)
yield call(loadUser, payload)
}
}
function* loadUser({ userId }) {
// do some side effect here
}
````
### Messenger
A `Messenger` is a list of `message`s associated with [redux] `reducer`s.
Helper functions help you to combine a `messenger` in the global [redux] `reducer`.
#### Create
````javascript
/// in /module/user/messages.js
import {
Messenger,
Action,
Types,
} from 'bdn-pocket'
import R from 'ramda' // <- yes we use Ramda a lot
const { string } = Types
const state = {
users: {
'user1': {
id: 'a',
name: 'name',
email: 'email',
}
}
}
export const user = Messenger
.add({
key: 'add',
action: Action
.propTypes('id', 'name', 'email')
.def('add user'),
reducer(state, { payload: { id, name, email } }) {
return R.assoc(
id,
{ id, name, email },
state
)
}
})
.add({
key: 'del',
action: Action
.propTypes('id')
.def('del user'),
reducer(state, { payload: { id } }) {
return R.dissoc(
id,
state
)
}
})
.add({
key: 'update',
action: Action
.propTypes({
id: string,
name: string(required: false),
name: string(required: false),
})
.def('update user'),
reducer(state, { payload: data }) {
return R.mergeWith( // <- yes, it is a special ramda trick
R.merge,
state,
{ [data.id]: userData }
)
}
})
.create({ name: 'user entities' }) // <- DO NOT FORGET TO CREATE YOUR MESSENGER INSTANCE
````
#### Combine with [redux] `reducer` (`makeReducer`)
````javascript
/// in /module/user/messages.js
import {
Messenger,
Action,
makeReducer, // <- here we added makeReducer
} from 'bdn-pocket'
import R from 'ramda' // <- yes we use Ramda a lot
// ... same code as before
export default makeReducer(user) // <- now you can combine this reducer with global redux reducer
````
If you define more than one `messenger` in a messenger file, you can use [redux] `combineReducer` helper to export a default reducer from your file
````javascript
/// in /module/user/messages.js
import {
Messenger,
Action,
makeReducer, // <- here we added makeReducer
} from 'bdn-pocket'
import R from 'ramda' // <- yes we use Ramda a lot
import { combineRecuers } from 'redux'
export const user = Messenger
.add({
...
})
.create({ name: 'user' })
export const account = Messenger
.add({
...
})
.create({ name: 'account' })
export default combineReducer({
user: makeReducer(user),
account: makeReducer(account)
}) // <- now you can combine this reducer with global redux reducer
````
#### Call message (from saga)
A `message` has to be dispatch in order to call associated reducer.
To create a `message` you have to use the message create accessible with `key` on `messenger`
````javascript
// in /module/user/sagas
import { take, call, put } from 'redux-saga/effects'
import * as userSig from '../signals'
import * as userMsg from '../messages'
export function* watchLoadUser() {
while(true) {
const { payload } = yield take(userSig.triggerLoadUser.CONST)
yield call(loadUser, payload)
}
}
function* loadUser({ userId }) {
// do some side effect here ...
// here we assume we receive a user data from our server
const userData = {
id: 'me',
name: 'Arnaud',
email: 'amelon-flower.com',
}
// add is the key of Message in messenger
yield put(userMsg.add(userData)) // <-- create message -> dispatch (put) -> call reducer -> new state
}
// other exemple
function* delUser({ userId }) {
// do some server stuff ...
// now delete user in state
// del is the key of Message creator in messenger
yield put(userMsg.del({ id: userId }))
}
````
#### Path reducer (`makePathReducer`)
Sometimes (often) you want to use payload property as key of a substate.
In our previous exemple, we use `payload.id` to put a specific user data under this sub state.
````javascript
// example of our users state
const state = {
users: {
me: { // id is used as key in our users sub state
id: 'me',
name: 'Arnaud',
//...
}
}
}
````
To facilitate this common pattern, we use `makePathReducer`.
````javascript
/// in /module/user/messages.js
import {
Messenger,
Action,
Types,
makePathReducer,
} from 'bdn-pocket'
import R from 'ramda' // <- yes we use Ramda a lot
const { string } = Types
const { DELETE_KEY } = makePathReducer
// here state manipulation is easier
// state in reducer is directly `users.id` (in first exemple it was `users`)
export const user = Messenger
.add({
key: 'add',
action: Action
.propTypes('id', 'name', 'email')
.def('add user'),
reducer(state, { payload }) {
return payload
}
})
.add({
key: 'del',
action: Action
.propTypes('id')
.def('del user'),
reducer(state, { payload: { id } }) {
return DELETE_KEY // special trick => will remove key from state
}
})
.add({
key: 'update',
action: Action
.propTypes({
id: string,
name: string(required: false),
name: string(required: false),
})
.def('update user'),
reducer(state, { payload: data }) {
return R.merge(state, data)
}
})
.create({ name: 'user entities' }) // <- DO NOT FORGET TO CREATE YOUR MESSENGER INSTANCE
export default makePathReducer(
user,
(payload) => payload.id
// or even simpler
// ({ id }) => id
)
````
### Selector & SliceSelector
[reselect] is a wonderfull library but it misses `selector` with `arguments`.
With `Selector` you can send props to your `selector` to make some filtering or choices.
You can ensure your props as `Selector` is composed of `PropTypes`.
`Selector` used [reselect] under the hood and implements it's own memoization to handle props.
You can compose a `Selector` with another `Selector` (see `getArticle` example)
A composed `Selector` (see `userSel` in `getArticle`) return a partial function that is memoized once.
It is usefull for computation selection.
Do not use `Selector` to get a slice of state.
Use `SliceSelector` in this case.
As `Selector` memoizes the last `reducer` result, if you want to only get a portion of your state without any computation, it won't be performant to run memoization and props comparison check.
````javascript
import { Selector, SliceSelector } from 'bdn-pocket'
const state = {
"articles": {
"123": {
id: "123",
author: "1",
title: "My awesome blog post",
comments: [ "324" ]
}
},
"users": {
"1": { "id": "1", "name": "Paul" },
"2": { "id": "2", "name": "Nicole" }
},
"comments": {
"324": { id: "324", "commenter": "2" }
}
}
const getSlice = (name) => (state) => state[name]
const getUsers = getSlice('users')
const getUser = SliceSelector
.selectors({
users: getUsers
})
.propTypes('userId')
.create({
// first arg = list of sub states
// second arg = list of props send
reducer({ users }, { userId }) {
return users[userId]
}
})
const getComments = state => state.comments
const getComment = SliceSelector
.selectors({
comments: getComments,
})
.propTypes('commentId')
.create({
reducer({ comments }, { commentId }) {
return comments[commentId]
}
})
const getArticles = state => state.articles
const getArticle = Selector
.selectors({
userSel: getUser,
commentSel: getComment,
articles: getArticles,
})
.propTypes('articleId')
.create({
reducer({ userSel, commentSel, articles }, { articleId }) {
// userSel & commentSel are partial functions that wait for theirs props ({userId} for userSel, { commentId } for commentSel )
const article = articles[articleId]
const comments = article.comments.map(
commentId => {
const comment = commentSel({ commentId })
const user = userSel({ userId: comment.commenter })
return {
comment,
commenter: user
}
}
)
return {
article,
comments
}
}
})
````
[redux]: https://github.com/reactjs/redux
[redux-saga]: https://github.com/redux-saga/redux-saga
[reselect]: https://github.com/reactjs/reselect
[b-eden]: http://beden.b-flower.com/
[stampit]: https://github.com/stampit-org/stamp/tree/master/packages/it
[Principles]: #principles