Our Experience with Creating Reusable Functional Components with React, Redux, and Redux-Loop Like many other companies, we here on the Kickstarter front-end team have been rewriting our site as a React app, with Redux to handle application state. This post is about our investigations into and ultimate solution for one issue we ran into in […]
Like many other companies, we here on the Kickstarter front-end team have been rewriting our site as a React app, with Redux to handle application state. This post is about our investigations into and ultimate solution for one issue we ran into in our work: namespacing actions.
I’ll cover why we wanted to namespace our Redux actions, the variety of available approaches, and the specific constraints we were working under before detailing our solution. (But feel free to scroll down to that last section first.)
With Redux, you can use combineReducers to create nested reducers that only operate on a slice of state, but all reducers still respond to all actions. Often this is the point—a component can affect another component just by dispatching an action. But when we started creating multiple instances of the same component, we created a system where every instance responded to action meant for just one.
Consider these instances of a div that changes color on hover. The intention is that just the instance being hovered over should change. But that’s not how it works.
Why not? When you use combineReducers with Redux, you are creating a nested reducer where the keys match your state keys. So for instance, something like this:
results in a reducer with a shape like this:
When an action is dispatched, each reducer is called with that action and the slice of state which corresponds to its name. In this case, boxOne is called with state.boxOne , and boxTwo with state.boxTwo . This means that if an action is dispatched by one version of a component, something like:
then it is responded to by both components. You get behavior you don’t want.
We began with an initial research pass. In this stage, we took a look at commonly suggested solutions from the community: using local state and three approaches to manual namespacing via action type string.
One common solution in a case like this is to give each component a local state and to let it handle its own interactions.
In addition, using redux-loop for our middleware means both state updates and side effects are triggered by the reducer. A typical reducer condition with redux-loop would look like this:
We are therefore very incentivized to keep all changes funneled through the reducer. If it can change independently, bugs can be harder to track down and behavior harder to explain.
For the last few years, the first approach for namespacing actions without local state has been to namespace the strings manually.
This can take a few forms, for instance using the feature name as a namespace:
This can be augmented or replaced by placing all your action constants in a single big file so that they cannot clash. Though in this case, it would be possible still to assign the same string to different constants in a big file, for example:
This can be addressed by using a unique string to be sure that even if constant names are reused across files, the action is namespaced:
Unfortunately, these are manual interventions. But what about cases where we wanted to add an arbitrary number of components to a page — say a shipping country select to each reward in a project, which could range from zero to nearly infinite — what then?
In addition to eschewing class-based components and manual solutions, I was particularly interested in a solution that complemented the component architecture my feature team was working with—what we called amalgamated components. These were higher-level components that encapsulated a feature unit, like a payment form or a custom select element: something that would exist at the molecule or organism level in atomic CSS. It comprises the display component, which may take any number of event handlers and a wrapper component that takes state and dispatch and binds the default events.
This way, if someone later down the road needs to use their own reducer and handler functions, they may grab the inner component, but in general, other teams instantiating the component should have a very low-surface-level API to work with. They would be able to import the component, its reducer, and its default state into the top-level file for the mount node, use combineReducers, and otherwise not have to fiddle with the component.
The preferred solution would work with this approach and allow us to keep things as encapsulated as possible for easy instantiation.
In this way, the first level of research allowed us to flesh out what requirements a successful solution would support.
We needed a way to namespace actions that:
With these constraints in mind, we identified three solutions: nested reducers, higher-order reducers, and the module pattern, and wrote up an RFC for the front-end team to consider.
The first solution we tried was to use nested reducers, which was inspired by the Elm architecture. At the time of the RFC, it had been implemented in a few locations in our codebase.
As this example makes plain, however, this approach contravened the goal of simple instantiation. While it’s likely possible to write generator functions in order to avoid manual instantiation, the path to that is definitely not straightforward. Many members of our team also felt this approach seemed unneccesarily complex.
Focusing on reducers that could be more straightforwardly be generated for namespacing, brought us to higher-order reducers. This approach centers on a reducer generator function that returns a reducer that only executes when called with a named action.
It would be paired with a higher-order action creator:
This solution hit the programmatic constraint pretty well, but broke down amid the low–API surface area desires.
The module pattern was a popular way of namespacing functions back in the “old” days of ES5 and worked by creating an immediately-invoked function expression (IIFE), which would use the power of closures to create functions that would not clash with one another.
For instance, with this example counter, the variable numcan be operated on by the functions in the returned object, but it will not clash in case the same name is used elsewhere in the code.
Applying this to our problem brought us to this suggestion:
which was very promising. It:
The obvious downsides to this approach were the need to wrap the entire component in the scoping function and the reliance on string interpolation.
The latter became more than a downside when we tried to apply the pattern within our concurrent TypeScript experiment. Namespacing the action strings interfered with the team’s ability to declare action types as a union type on the action’s string.
But we forged ahead. And we succeeded!
In the end, we settled on a solution that combined elements from higher-order reducers and the module pattern. Instead of using string interpolation, we added a namespace value to our actions and applied the higher-order reducer pattern. In terms of the module pattern from above, the change results in a module that looks like this:
In order to mitigate the other drawback — being forced to work inside the closure—we added a set of utility functions to add the namespaces in.
This way, a developer could implement the namespacing elements in her module as she saw fit. The only requirements were that reusable components should provide a namespacing function that could be called with a namespace string but otherwise would default to a uuid. This function would return:
In addition to this function, the component is expected to provide an initial state with appropriate defaults/blank state, suitable for being folded into the top-level state.
The utilities also include an all-in-one module namespacing function that allows a developer to abstract namespacing to the factory functions:
It is worth noting that the namespacing function provides for a number of arguments, not just the namespace itself, but also the component, reducer and actions. This was put in place to address cases where components might need to be wrapped before being passed to the namespacer, for instance a themed component. In that case, a component might be a curried function that takes its theme as the first argument and then returns the wrapper component. To namespace this, the themed component could be passed as part of the options, supplanting the usual version.
The primary challenge we have encountered with this approach so far has been keeping actions namespaced as we pass them through reducers. Whenever the result of a dispatched action is to further dispatch other actions, those actions need to maintain their namespace.
We’ve chosen to assure this by binding actions to reducers, usually through partial application on initialization:
In a one-step initialization, as above, this works well. We have run into a few instances of complex composition, however, where the binding has gotten lost and caused errors.
The other drawback to this approach is that instead of relying on shared scope for actions returned from promises, we need to pass the namespaced version as an argument.
The verbosity is a reasonable trade-off here, though, since the handler code needs only to be written once, but components will be instantiated multiple times.
And despite the kinks, we have found this approach to work successfully for most cases, including compound components that wrap a base reusable component with greater functionality, like a base uploader that can be wrapped to become an image uploader or a video uploader or a custom select that can have async option fetching also added in.
One larger concern with this approach is that at some point there may be too many reducers and that will result in a performance hit. There are a few ways to approach mitigation here, from libraries that help single out subreducers to ignore to reconstituting reducers as hash-maps instead of case statements. But we don’t need to solve problems before we hit them, so we’ll be sticking with our adapted modules for now.