Dynamic Redux Reducers
This post is specific to a need I had on recent React / Redux project. It’s a common need and one I’d run into before, but this was the first time I needed to come up with a solution for it. This was difficult for me. I had to slow down and take time to internalize what I was trying to do and all the pieces involved. My hope is that this post will help someone else also working to figure this out.
I’ll detail my process in this post. Here’s a live demo and an editable sandbox. The best way to see the effects is to use the Redux DevTools Chrome extension.
If you’re reading this I’m going to assume you have knowledge of Redux and
are using it with React by way of react-redux
. I'm also going to
assume you’re looking for a solution to a similar problem.
What am I trying to do and why?
In standard Redux usage, you provide reducer functions at the time you create
the store with createStore
. I wanted a way to add reducer functions
later, on demand.
A lot of folks need this because their reducers are not available at
createStore
time due to code-splitting. That’s a perfect use for
dynamic reducers.
My project doesn’t use code-splitting. For this, dynamic reducers were a preference. I didn’t want to spread info about modules throughout the project structure. I wanted each feature to live in a directory, isolated as much as possible. That meant co-locating reducers, components, styles, and so on. I could do that and still import the reducers to the main reducer creation, but that would couple module reducers to the main reducer.
Existing solutions
In my Googling for an existing solution I landing on this Stack Overflow question and answer. The answer is from Dan Abramov so I knew it was a way to go. My solution uses most of the code from that answer.
In Dan’s answer, it all made sense to me until his example of how to inject reducers. I’m using React Router, but I don’t define routes the way he described. I didn’t want to have to change how I defined my routes for this. I also couldn’t find official documentation for methods he used in his example so I wanted to avoid copy / paste. I also wanted to fully understand the code I was adding to my project.
It’s worth mentioning two projects I came across in my search. redux-dynamic-reducer and paradux. I didn’t try either of them because I didn’t see the need in adding another dependency, but they might work for you.
What the demo shows
The demo shows a simple page
with a link to /records
. When page loads, the Redux state tree
contains two keys. One for each reducer function introduced at store creation.
There’s a link to the /records
page. When you navigate to that
page, I add another reducer function for Records. In the rest of this post
I’ll decribe how I do that.
The code
You can follow along in the CodeSandbox.
I’ll start with creating the root reducer in /rootReducer.js
.
import { combineReducers } from "redux";
import layout from "./reducers/layout";
import home from "./reducers/home";
/**
* @param {Object} - key/value of reducer functions
*/
const createReducer = asyncReducers =>
combineReducers({
home,
layout,
...asyncReducers
});
export default createReducer;
I pulled this code from Dan’s SO answer.
It has two reducer functions; layout
and home
.
They’re global reducers, not module level, so they fit well in the root reducer.
The key detail here is the asyncReducers
parameter. Adding the
contents of it to the object given to combineReducers
is
how we add reducers later.
Next up is store creation in /initializeStore.js
. Again, most
of this code is from Dan’s example.
import { createStore } from "redux";
import createReducer from "./rootReducer";
const initializeStore = () => {
const store = createStore(createReducer());
store.asyncReducers = {};
store.injectReducer = (key, reducer) => {
store.asyncReducers[key] = reducer;
store.replaceReducer(createReducer(store.asyncReducers));
return store;
};
return store;
};
export default initializeStore;
The first line of initializeStore
is where we create the Redux
store with the initial reducers from createReducer
. In standard
Redux usage, this is all you’d need. The store is set up and ready with the
home
and layout
reducers.
createStore
returns a plain object, so we’ll take advantage of that
by tacking helpful items onto it. We’ll use store.asyncReducers
to house
our dynamic reducers. With store.injectReducer
I deviate from
Dan’s example. The function does the same thing as his injectAsyncReducer
,
but I attach it to the store
object for convenience that I’ll
show later.
injectReducer
has two responsibilities. First, store all dynamic
reducers in asyncReducers
. This ensures that each time we invoke
injectReducer
we don’t lose other dynamic reducers. Next up
is the main work. replaceReducer
isn’t custom, it’s part of Redux. It does does what it says on the tin.
Invoking it replaces the reducer function with one you give it.
Where things got tricky for me
At this point everything seemed straightforward to me, but then I got lost fast. I have a store, I have a function to add new reducers. But where can I access that function to invoke it? In all my frantic Googling, I couldn’t find an example that worked for my setup. So, I sat down to figure out a solution.
It took me a while to figure out where I could access that store
object. I had clues though. In my entry point file /index.js
I
use the Provider
component. This is standard for React / Redux projects.
import React from "react";
import { render } from "react-dom";
import { Provider } from "react-redux";
import initializeStore from "./initializeStore";
import App from "./App";
const store = initializeStore();
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
Giving the store
to Provider
makes it available to
all child components by way of the connect
function. I read more about it and learned that store
is also available in the context
of each component.
If you’ve read anything about
React context
, you’ve read that you probably shouldn’t use it.
For my purposes here it seemed isolated enough to be OK. Time will tell if that’s correct or not.
More details on my context
usage to later.
Putting the pieces together
I want to use as little code as possible to add reducers. I do that with a
higher-order component
in /withReducer.js
.
import React from "react";
import { object } from "prop-types";
const withReducer = (key, reducer) => WrappedComponent => {
const Extended = (props, context) => {
context.store.injectReducer(key, reducer);
return <WrappedComponent {...props} />
};
Extended.contextTypes = {
store: object
};
return Extended;
};
export { withReducer };
And example usage in routes/Records/Records.js
:
import { withReducer } from "../../withReducer";
import reducer from "./ducks";
const Records = () => (...);
export default withReducer("records", reducer)(Records);
I’ll start with usage in Records.js
. I import the records
reducer from routes/Records/ducks/index.js
. The reducer doesn’t
do much. It sets hard-coded initial state, then returns it as-is.
The component acts like a container component.
I could connect
it, but for the purposes of this
demo, left it out.
The pertinent bit is the last line. There I invoke withReducer
and
provide it a key of “records” and the record reducer. Then I invoke the
returned function, providing the Records
component.
Records
is a React component I import to use as
the value of the component
property of a React Router <Route />
.
The withReducer component
withReducer
is a Higher-Order Component.
The key
parameter becomes the key in the Redux state tree.
The reducer
parameter is the reducer to add. It returns a function
that accepts a single parameter, WrappedComponent
. That’s expected
to be a valid React component. In the earlier usage example, that’s the
Records
component.
I’ll jump ahead to an important part of withReducer
that was
new to me and might be confusing.
...
Extended.contextTypes = {
store: object
};
...
Extended
is a stateless component, so it must define
a contextTypes
property to gain access to context
.
From the React docs:
Stateless functional components are also able to reference context if contextTypes is defined as a property of the function.
reactjs.org/docs/context.html#referencing-context-in-stateless-functional-components
In contextTypes
I defined the property I want to access in the
component, store
. That uses the
object
type from the prop-types
library.
When a component defines a contextTypes
property, it receives
a second parameter, context
. That’s visible in
the Extended
signature:
...
const Extended = (props, context) => {...}
...
Extended
now has access to the store
object. That’s
because <Provider store={store}>
in /index.js
makes it available to all child components via context
.
This happens in the Provider.js
source with
getChildContext
and childContextTypes
.
That code is good reading if you’re looking for examples of context
usage.
In initializeStore.js
I created a function on the store object,
store.injectReducer
. Now, I use that to add the new reducer:
...
const Extended = (props, context) => {
context.store.injectReducer(key, reducer);
return <WrappedComponent {...props} />;
};
...
The orginal component doesn’t change. Extended
only returns
it with any original properties.
How to see this working
At this point, the code works. But, this type of change can be difficult to visualize. As mentioned earlier, the Redux DevTools Chrome extension. works best for me. In the demo I included the devtools snippet when creating the store. If you install the extension and view the Redux panel, you can see new reducers change the state tree.
To further show the results in the demo, I connect
ed the record
route to display record data from the store.
...
const mapStateToProps = (state, props) => {
const { match: { params: { id } } } = props;
return {
recordId: id,
record: state.records[id] || {}
};
};
export default connect(mapStateToProps)(Record);
The full code is in /routes/Records/routes/Record.js
.
A solution
As I mentioned earlier, this is a common need in React/Redux projects for different reasons. I’ve used other, similar methods for dynamic routes in the past. Other folks have different approaches. With that, this is a solution, not necessiarly the solution.
If this is helpful and you use it as-is or change it to fit your needs, let me know. There’s always room for improvement.