Introduction
React and Redux offers a powerful and flexible architecture for modern web applications. It enforces unidirectional data flow to initiate, manage and react to application state mutations, also provides tools and utilities to build large scale complex applications. Redux constructs, like reducers and combined reducers, enables managing state in a logical divisions as if different Flux stores for different states. However, there is no standard pattern to handle multiple instances of the same reducer, when opening up multiple instances of domain objects, we need a practical and less intrusive solution.
For example, you've already got Redux store with combined reducers work with one item (domain object), how to handle multiple instances at the same time, like an array of entries or items? The standard splitting / combining reducers doesn't help here, because state will pass to components as props data with mutations, while reducers are essentially a function that handles actions to manage and mutate state.
Additionally, since current combined reducers, actions and components have been working and tested for single instance, we'd better to reuse existing code when putting them into a collection.
We've solved the problem with two steps. First, decoupling all react components from store shape, make them only operates within a logical boundary of "component props". Second, managing a collection of states in an array and apply the same combined reducer in a higher level reducer.
The decoupling step helps to keep components intact while re-architecture the store shape, and the collection-ization step is to utilize existing reducers and actions to a specific selected instance's state without substantial changes.
The benefit of this two steps approach is the scope of changes are contained in higher level reducers, all components become agnostic to store shapes. When evolving a fairly complex and large application architecture, these two benefits are essential to flexibility and maintainability, ultimately project schedules and qualities.
Background
The requirement for multiple instances comes in a fairly late in our project. The application has been built without the notion of multiple domain objects opened / edited at the same time. All components, actions, reducers have been developed for single domain object, and object state is managed by a combined reducer. Now we need to open different domain objects in its own tab, so that user can easily switch between them.
We found Redux Selector Pattern is a great helper to the decoupling step, it encapsulates the knowledge about state shape in selectors that are collocated with reducer, so that component doesn't depend upon state structure, component code doesn't need to change when re-shaping the data store.
When current selected instance is changed, actions and components will automatically switch to selected state, no need to run repetitive and ever-changing logic to get the state for rendering, since the selectors essentially eliminate the dependencies on store shape.
With selectors in place, we can simply remove the single instance's combined reducer from root state. When a new domain object is opened, after a tab created, the combined reducer and its initial state will be associated with the tab state. And when action is dispatched, we can manually invoke the same combined reducer with current selected tab state. Then components render and React virtual DOM updates the DOM with the selected object data in the tab array.
Let's see some code.
Using state selectors
Assuming AEditor
is the component for our domain object, a single editor component code looks like this without selectors:
const AEditor = React.createClass({
componentWillMount() {
...
},
componentWillReceiveProps(nextProps) {
...
},
render() {
return ( <lowerlevelcomponent {...this.props.editorstate} />);
}
});
export default connect(
state => ( {editorState: state.editorContainer.editorState} ),
dispatch => { (actions: bindActionCreators(actions, dispatch)} )
)(AEditor);
Notice the editorState
is retrieved directly from root state, but the component only need editorState
to render. Even without the need for multiple AEditor
instances, it'd be better to eliminate the dependency on state shape, this is where selector comes to help.
Here is the same component code with selectors:
const AEditor = ... ;
export default connect(
state => ( {editorState: getEditorState(state) } ),
dispatch => { (actions: bindActionCreators(actions, dispatch)} )
)(AEditor);
getEditorState
is the selector, all it does is to take the root state as input, and returns the editorState
. Only the selector knows the store shape, when current selected editor changes, it'll return the selected editor state back. This is how we can keep AEditor
component and related actions as is when it has an array of objects.
Here is the implementation of getEditorState
in root reducer:
export const getEditorContainer = (state) => state.editorContainer;
export const getEditorState = (state) => fromEditor.getEditor(getEditorContainer(state));
Nothing really special here, these selector functions job is to "select" the correct state to the components. For multiple instances of states, they're critical to make current Actions work with selected object state.
Now that component is decoupled from store shape, we can move on to creating array of editor states.
Creating initial states
The editorContainer
is actually a combined reducer:
export const editorContainer = combineReducers({
editorState,
propertiesState,
outlineState,
categoryState,
dataPaneState
});
where each xxxState
is a reducer. For example, here is the reducer.editor.state.js
file that implements editorState
:
const initialState = {
actId: '',
actProps: undefined,
actSteps: undefined,
deleted: false
};
export const editorState = (state = initialState, action = {}) => {
switch (action.type) {
case types.NAVIGATE_WITH_ACTION_ID: return onNavigateWithActionId(state, action);
...
default: return state;
}
};
Before we can apply the combined reducer editorContainer
to a collection, we need a selector of initial state:
export const getInitialEditorContainerState = () => ({
editorState: initialEditor,
propertiesState: initialProperties,
outlineState: initialOutline,
categoryState: initialCategory,
dataPaneState: initialDataPane
});
All we need to do is to export each initialState from the sub reducers, then import them for the initial state selector:
export const initialState = {
actId: '',
...
};
...
import { initialState as initialEditor } from "../editor/reducer.editor.state";
export const getInitialEditorContainerState = () => ({
editorState: initialEditor,
...
});
Once we have the initial state for the combined reducer, all the rest is pretty straight forward.
Applying combined reducer
Our goal is to enable user opening / editing multiple AEditor
instances in its own tab. Since tabsState
already has an array of tab
, when creating a new tab, we can set the initialEditorContainerState
as the tab's contentState
, also set the editorContainer
combined reducer as its contentReducer
.
Here is the code invoked after newTab
is created in tabsState
reducer:
function _setTabContent(newTab) {
if (newTab.tabType === AppConst.AEDITOR) {
if (!newTab.contentState || typeof(newTab.contentState) !== "object") {
newTab.contentState = getInitialEditorContainerState();
}
if (!newTab.contentReducer || typeof(newTab.contentReducer) !== "function") {
newTab.contentReducer = editorContainer;
}
}
}
Next, we need to invoke contentReducer
to update contentState
in tabsState
reducer:
function onContentActions(state, action) {
let updatedState = state;
let curTab = state.tabs[state.selectedIndex];
if (curTab && curTab.contentReducer) {
let updatedTabs = state.tabs.slice();
updatedTabs.splice(state.selectedIndex, 1, {...curTab, contentState: curTab.contentReducer(curTab.contentState, action)});
updatedState = {...state, tabs: updatedTabs};
}
return updatedState;
}
export default function tabsState(state = _initialTabsState, action) {
switch (action.type) {
case AppConst.OPEN_OR_CREATE_TAB: return onOpenOrCreateTab(state, action);
case AppConst.CLOSE_ONE_TAB: return onCloseOneTab(state, action);
case AppConst.UPDATE_ONE_TAB: return onUpdateOneTab(state, action);
default: return onContentActions(state, action);
}
}
Notice onContentActions
update the current tab by calling curTab.contentReducer(curTab.contentState, action)
, this is the key to make sure all current Actions and Components work with current selected tab content.
Associating tab state with contentState
and contentReducer
gives us flexibility when tabs extended to host different type of components. When your use case is not in tabs, same idea and technique still applies.
Wrap up
Although no standard pattern to apply reducers to a collection, the selector pattern and invoking single instance's reducer with selected state from an array is a practical and efficient solution.