Local nested state for Redux components
Redux is great, but the global actions and global state are limited. Handling local state at the global level defeats encapsulation and there are discussions on topic both in Redux and in React projects.
Cursors are reasonably popular tools to solve this issue in general. Redux does not like the cursors in their general implementation, but the criticism is focused purely on the low-level ability to mutate the state at will. redux-cursor
resolves that by relying on actions just as base Redux.
Other solutions to this problem: redux-react-local, vdux-local, redux-brick, redux-component, redux-state.
In the 3 steps you will be creating a full integration with redux-cursor. In short, we will use private state, make a local reducer, and a local action.
For your top-level component MyApp
, create a private reducer:
const reduxCursor = require('redux-cursor')
const myAppReducer = reduxCursor.makeLocalReducer('my-app', {})
module.exports.myAppReducer = myAppReducer
Connect this top-level private reducer to your Redux store reducer:
const cursorRootReducer = reduxCursor.makeRootReducer(myAppReducer)
module.exports = function(state, action) {
state = combineReducer({
// You probably have your global reducers here
})
state = cursorRootReducer(state, action)
return state
}
Great, now Redux knows about your private reducer. Next, let us make it useful. Let us return to the reducer and give it some private state:
const reduxCursor = require('redux-cursor')
const myAppReducer = reduxCursor.makeLocalReducer('my-app', {
isPopupOpen: false
})
module.exports.myAppReducer = myAppReducer
This private state will be stored in the store, but will be visible only to MyApp
. Let us make it visible to MyApp
component. Modify the MyApp
construction to include a new property, cursor
:
<MyApp cursor={makeRootCursor(store, myAppReducer)} />
Now inside the MyApp
you can access the private state with props.cursor.state.isPopupOpen
. Yes, this is longer than state.isPopupOpen
, but the state is now explicitly stored in the store.
Now we need to modify this state. In Redux, we only modify the state by dispatching actions, and redux-cursor promised you private actions. Letโs make one now in your private reducer:
const reduxCursor = require('redux-cursor')
const myAppReducer = reduxCursor.makeLocalReducer('my-app', {
isPopupOpen: false
})
module.exports.userClicked = myAppReducer.action('user-clicked',
() => ({ isPopupOpen: true }))
module.exports.myAppReducer = myAppReducer
We have added a userClicked
action that changes the isPopupOpen
to true
. For more thorough explanation of the action syntax, see this documentation a bit further.
To perform an action, we need to dispatch it. But we have to dispatch it through our Cursor in MyApp
:
const onClick = function(){
props.cursor.dispatch(userClicked())
}
This modifies the store with our private change. Notice that a Cursor object is immutable, and thus whoever handles store changes in your application, must re-render the <MyApp>
with a new makeRootCursor
. If that happened, MyApp
will now see a new props.cursor.state.isPopupOpen
.
The final piece of the puzzle is handling component trees. Let us assume that you have a Settings
component in your MyApp
. First of all, make a private reducer for the Settings
:
const reduxCursor = require('redux-cursor')
const settingsReducer = reduxCursor.makeLocalReducer('settings', {
sendNewsletter: false
})
module.exports.userExpressedNewsletterPreference = myAppReducer.action('newsletter',
({ param }) => ({ sendNewsletter: param }))
module.exports.settingsReducer = settingsReducer
Now you need to connect this reducer to its parent. Modify the parent appReducer
to include the settingsReducer
in the list in the third parameter:
const reduxCursor = require('redux-cursor')
const settingsReducer = require('./settings/reducer')
const myAppReducer = reduxCursor.makeLocalReducer('my-app', {
isPopupOpen: false
}, [settingsReducer])
That parameter should include private reducers of all direct children of the component. Finally, pass the cursor
prop with its own state to the Settings
in MyApp
render method:
<Settings cursor={this.props.cursor.child(settingsReducer)} />
Now Settings
has its own cursor
with its own cursor.state
and a further, nested, cursor.child
. If you ever include multiple copies of the same child component, pass a string key as the second parameter to .child
call, similar to Reactโs key parameter:
<Tags cursor={this.props.cursor.child(tagsReducer, 'post-tags')} />
<Tags cursor={this.props.cursor.child(tagsReducer, 'category-tags')} />
This is it! Now every component has their own slice of the store with their own private actions and state.
props.cursor.state
is the private state of the component. Its shape is defined by the component reducer.
props.cursor.dispatch
takes private cursor actions and modifies the store in accordance with the actions.
props.cursor.globalState
and props.cursor.globalDispatch
are convenience shortcuts to link to the Redux store. There is no logic stored here, those come directly from the Redux store.
props.cursor.dispatchGlobal
is deprecated.
props.cursor.child(childReducer, [childKey])
creates a child cursor
object based on the childReducer
and an optional string key. The reducer must be included in the parentโs reducer creation, but if you forget, a console warning will remind you. The key is optional and used to distinguish multiples of the same kind of children in the same parent. Note that if you use the same child in two different places in the component hierarchy, key parameters are not needed.
The general form of an action is a string name that only needs to be unique across all actions of this specific reducer, and then a reducer function:
myAppReducer.action('action-name', function(options) {
return changes;
})
Reducer functions should return changes to the current private state:
const myAppReducer = reduxCursor.makeLocalReducer('my-app', {
isPopupOpen: false,
isDropdownOpen: false
})
const userClicked = myAppReducer.action('user-clicked', function(){
return { isPopupOpen: true }
})
The reducer function receives the local reducer state:
const myAppReducer = reduxCursor.makeLocalReducer('my-app', {
clickCount: 0
})
const userClicked = myAppReducer.action('user-clicked', function(env){
return { clickCount: env.state.clickCount + 1 }
})
The reducer function also receives the parameter given to the action creator. Only one parameter is allowed, use an object to pass more:
const userChangedName = myAppReducer.action('user-changed-name', function(env){
return { name: env.param }
})
props.dispatch(userChangedName(input.getValue()))
Although the general idea of the module is to limit local actions and reducers, performing global actions is often necessary. It could be analytics, or user desktop notifications or other things that you might want to trigger in a local event.
One workaround is to dispatch
multiple actions in your event handlers. One local and a few global. Redux community seems to use that a fair amount. redux-cursor
authors, however, consider this to be an anti-pattern. The canon way to perform global changes in local reducers is to trigger global intents:
const userChangedName = myAppReducer.action('user-changed-name', function(env){
env.global('tracking', { code: '1.2.3' })
return { name: env.param }
})
props.dispatch(userChangedName(input.getValue()))
It is notable that the local reducer does not have access to the global state. This allows to make components with their local reducers reusable in different applications with different global states.
These global intents need to be handled in the root reducer, similar to handling actions:
const cursorRootReducer = reduxCursor.makeRootReducer(myAppReducer, function(globalState, type, param){
if (type === 'tracking') {
// modify global state and return a different one
}
return globalState
})
It is notable, that they are not separate actions and are fully under the control of the local reducer. This means that an updated hot-reloaded reducer replayed over the same list of real actions may choose to trigger a different set of global intents, resulting in a different global state.