miaoYu

Namespacing Actions for Redux

原文链接: kickstarter.engineering

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 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.)

The code examples in this post for both the problem and the solution are available in their totality on CodePen.

The Problem

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.


Interacting with one box affects them both. Try it yourself on CodePen.

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:

import { initialState as boxState } from 'hue-box/state.js';
import { reducer as hueBox } from 'hue-box/reducer.js';

const combinedStates = {
 // spread to make separate copies of the state
 boxOne: {...boxState},
 boxTwo: {...boxState},
};

const combinedReducers = combineReducers({ boxOne: hueBox, boxTwo: hueBox });
const store = createStore(combinedReducers, combinedStates);

results in a reducer with a shape like this:

topLevelReducer = {
 boxOne: (state, action) => { .. },
 boxTwo: (state, action) => { .. }
}

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:

// in the wrapper
updateHue={(currentHue) => dispatch(onUpdateHue(currentHue))}

// on the div itself
onMouseEnter={updateHue.bind(null, state.hue)}

then it is responded to by both components. You get behavior you don’t want.

The First Pass: Common Solutions and Identifying Constraints

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.

Local State

One common solution in a case like this is to give each component a local state and to let it handle its own interactions.

However, our current front-end setup uses only functional components—functions that take props and return JSX components; no class ... extends React.Component. This choice made it easier to move to React for folks with less of a Javascript background. We were free from the this keyword and from ES6 class idiosyncrasies. Pure components were simpler to test.

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:

case 'ACTION_WITH_SIDE_EFFECT':
  return loop (
    // loop is provided by redux-loop and is called with the usual state update
    {
      ...state,
      actionToggled: true
    },
    // and a second command argument that can run a function, return
    // an action, or kick-off a promise
    Cmd.run(trackClick)
  );

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.

Manual Namespacing

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:

const TOGGLE_BUTTON = 'feature/TOGGLE_BUTTON';
const onButtonToggle = (payload) => { type: TOGGLE_BUTTON, payload)

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:

export const TOGGLE_VIEW_BUTTON = 'view/TOGGLE_BUTTON';
  /* ... 400 lines later */
export const FLIP_V_BUTTON = 'view/TOGGLE_BUTTON';

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:

// cool-feature.js
const TOGGLE_VIEW_BUTTON = uuid();
// rad-feature.js
const TOGGLE_VIEW_BUTTON = uuid();

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?

One More Thing

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.

// wrapper component
export const HueBoxWrapper = ({state, dispatch }) => {
  return (
    <HueBoxDisplay
      state={state}
      updateHue={(currentHue) => dispatch(onUpdateHue(currentHue))}
      resetHue={() => dispatch(onResetHue())}
    />
  );
};

// display component
export const HueBoxDisplay = ({
  state,
  updateHue,
  resetHue
}) => {
  return (
    <div
      onMouseEnter={updateHue.bind(null, state.hue)}
      onClick={resetHue}
      style={{
        display: 'inline-block',
        width: '300px',
        height: '300px',
        backgroundColor: `hsla(  ${state.hue}, 100%, 50%, 1)`,
        margin: '20px'
      }}
    />
  );
};

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.

const combinedStates = {
  // spread to make separate copies of the state
  boxOne: Object.assign({}, initialState),
  boxTwo: Object.assign({}, initialState),
};

const combinedReducers = combineReducers({ 
  boxOne: reducer, 
  boxTwo: reducer 
});

const store = createStore(combinedReducers, combinedStates);

const Main = ({ state, dispatch }) => {
  return (
    <div>
      <HueBoxWrapper
        state={state.boxOne}
        dispatch={dispatch}
      />
      <HueBoxWrapper
        state={state.boxTwo}
        dispatch={dispatch}
      />
    </div>
  );
};

The preferred solution would work with this approach and allow us to keep things as encapsulated as possible for easy instantiation.


Constraints

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:

  1. Did not involve using local state
  2. Worked with combineReducers
  3. Could be applied programmatically
  4. Had a low–API surface area, which is to say, did not ask to much of others using the resultant component

The RFC

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.

Nested Reducers

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.

// -------- INITIALIZE INSIDE COMPONENT REDUCER (bigger-app-section/reducer.js) -----------

import { reducer as hueBoxReducer } from 'hue-box/reducer.js';
import { liftState, Effects, loop } from 'redux-loop';

const boxOneReducer = liftState(hueBoxReducer);
const updateBoxOne = (subAction) => {
  return { type: 'UPADTE_BOX_ONE', subAction };
}

 ... /* repeat for box two */

const reducer = (state, action) => {
 ... /* many other reducer functions */

 case 'UPDATE_BOX_ONE':
  const [boxOneState, boxOneEffects] = coolInputReducer(
    state.boxOne,
    action.subAction
  );
  return loop(
    { ...state, boxOneState },
    Effects.batch([
      Effects.lift(boxOneEffects, onUpdateHue),
      Effects.call(otherAction, action.subAction.type) // this is an action in the parent reducer
    ])
  );

   ... /* repeat for box two */

}

// -------- WRAPPER (bigger-app-section/wrapped-boxes.js) -----------

// As with the higher-order reducers, the wrappers must be instantiated
// separately with different actions (or subActions) dispatched

export const HueBoxOneWrapper = ({ state, dispatch }) => {
  return (
    <HueBoxDisplay
      state={state}
      updateHue={(currentHue) => updateBoxOne(onUpdateHue(currentHue))}
      resetHue={() => updateBoxOne(onResetHue())}
    />
  );
};

 ... /* repeat for box two */

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.

Higher-Order Reducers

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.

// a function that returns a reducer

const namespaceReducer = (reducerFunction, namespace) => (state, action) => {
  const isInitializationCall = (state === undefined);
  if(action.namespace !== namespace && !isInitializationCall) return state;

  return reducerFunction(state, action);
};

It would be paired with a higher-order action creator:

// a function that returns an action creator
const namespaceAction = (actionCreator, namespace) => (...actionArgs) => {
  const action = actionCreator(...actionArgs);
  return { ...action, namespace };
};

This solution hit the programmatic constraint pretty well, but broke down amid the low–API surface area desires.

The Module Pattern

In the film Hidden Figures, there is a part where the mathematicians and engineers are struggling to figure out the best way to compute a trajectory and Katherine Johnson comes up with the solution by going back to “old” math. Though this is definitely a silly way to put it and almost certainly came from the pen of a screenwriter and not the mouth of a mathematician, it also aptly describes the final pattern we considered — a relic from old Javascript.

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.

var safeCounter = (function counter() {

  var num = 0;

  return {
    get: function () {
      return num;
    },
    set: function (val) {
      num = val;
    },
    inc: function () {
      num += 1;
    }
  }  
})();

safeCounter.set(3);
safeCounter.inc();
safeCounter.get(); // returns 4

Applying this to our problem brought us to this suggestion:

// default to uuid with dashes removed for safety if desired
export const namespacedHue = (namespaceString = uuid().split('-').join('')) => {

  // create consts interpolating the namespaceString into your action type
  const UPDATE_HUE = `${namespaceString}_UPDATE_HUE`;
  const RESET_HUE = `${namespaceString}_RESET_HUE`;

  const actions = {
    onUpdateHue: (hue) => {
      return { type: UPDATE_HUE, hue }
    },

    onResetHue: () => {
      return { type: RESET_HUE }
    }
  };

   // write the reducer like normal
  const reducer = (state, action) => {
    switch (action.type) {
      case UPDATE_HUE:
        return {
           ...state
           hue: action.hue + 30
        };
      case RESET_HUE:
        return {
          ...state,
          hue: 10
        }
      default:
        return state;
    }
  };

  const Component = ({ state, dispatch }) => {
    return (
      <HueBoxDisplay
        state={state}
        updateHue={(currentHue) => dispatch(actions.onUpdateHue(currentHue))}
        resetHue={() => dispatch(actions.onResetHue())}
      />
    );
  };


  return { actions, reducer, Component };

}

const { reducer: boxOne, Component: HueBoxOne } =
  namepsacedHue('THIS_NAMESPACE_IS_ONE');

const combinedReducers = combineReducers({ boxOne, boxTwo });

which was very promising. It:

  1. Did not involve using local state
  2. Worked with combineReducers
  3. Could be applied programmatically
  4. Had a low–API surface area: it required only state and dispatch to be instantiated, like the non-namespaced version.

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.

export type Action =
  | { type: 'HUE_BOX_INIT'; slug: string }
  | {
      type: 'HUE_BOX_RESPONSE';
      response: GraphResponse<BasicsQueryResponse>;
    }
  | { type: 'UPDATE_HUE' }
  | { type: 'RESET_HUE' }
  | { type: 'NOOP' }

But we forged ahead. And we succeeded!


Interacting with one box only affects that box. Try it on CodePen.

Our Solution: Where We Landed

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:

const makeNamespacedBox = (namespace = uuid()) => {

  const actions = {
    onUpdateHue: (hue) => {
      return { type: 'UPDATE_HUE', hue, namespace }
    },
onResetHue: () => {
      return { type: 'RESET_HUE', namespace }
    }
  };

  const reducer = (state={}, action) => {
    if (action.namespace !== namespace) {
      return state;
    }

    switch (action.type) {
      case 'UPDATE_HUE':
        return Object.assign(
          {}, state, 
          {hue: action.hue + 30}) 
      case 'RESET_HUE':
        return Object.assign(
          {}, state, 
          {hue: 10}) 
      default:
        return state;
    } 
  };

  const Component = ({state, dispatch }) => {
    return (
      <HueBoxDisplay
        state={state}
        updateHue={(currentHue) => dispatch(actions.onUpdateHue(currentHue))}
        resetHue={() => dispatch(actions.onResetHue())}
      />
    );
  };

  return { Component, actions, namespace, reducer };
}

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.

// a function that takes a namespace and turns a given reducer into a namespaced reducer
export const namespaceReducerFactory = (namespace) => (reducerFunction, actions) => (state, action) => {
  const isInitializationCall = (state === undefined);
  if((action && action.namespace) !== namespace && !isInitializationCall) return state;
return reducerFunction(state, action, actions);
};

// a function that takes a namespace and turns a given action creator into a namespaced action creator
export const namespaceActionFactory = (namespace) => (actionCreator) => (...actionArgs) => {
  const action = actionCreator(...actionArgs);
  return { ...action, namespace };
};

// a function that applies action factory to all members of an object
export const namespaceActions = (namespace) => (actions) => {
  return mapObject(namespaceActionFactory(namespace), actions);
};

// a function that takes a namespace and turns a given dispatch into a namespaced dispatch
export const namespaceDispatchFactory = (namespace) => (dispatch) => (action) =>
  dispatch({ ...action, namespace });

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:

  • namespaced actions with the structure { type, namespace, ...otherPayload }
  • a namespaced wrapper component that can be instantiated with state, dispatch, and optional configuration parameters
  • a namespaced reducer that handles passing the namespace through all actions, even those returned by redux-loop
  • the namespace itself (useful if users want to autogenerate namespaces but refer to them)

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:

// a function that takes an object containing the actions, reducer, and Component and
// adds a namespace, returning the object outlined by the namespaced action API

export const namespaceModule = (UnboundComponent, unboundReducer, plainActions) => (
  namespace = uuidNoDashes(),
  {
    // optional overrides object
    Component = UnboundComponent,
    reducer = unboundReducer,
    actions = plainActions
  } = {}
) => {

  const namespaceReducer = namespaceReducerFactory(namespace);
  const namespaceDispatch = namespaceDispatchFactory(namespace);
  const namespacedActions = namespaceActions(namespace)(actions);

  return {
    namespace,
    actions: namespacedActions,
    reducer: namespaceReducer(reducer, namespacedActions),
    Component: ({ dispatch, ...args }, context) =>
      Component({ dispatch: namespaceDispatch(dispatch), ...args }, context)
  };
};

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.

// a themed component
const ThemedFriend = (theme) => ({ state, dispatch }) => {
  return (
    <WrappedThemedComponent
      state={state}
      dispatch={dispatch}
      theme={theme}
    />
  );
};

// usual namespace setup
const namespaceTheFriend = namespaceModule(
  WrappedThemedComponent, 
  friendReducer, 
  friendActions
);

// overwriting the Component
const ThemedAndNamespaced = namespaceTheFriend(
  'NAMESPACE', 
  { Component: ThemedFriend }
);
view raw

Challenges & Successes

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 the module file
export const unboundReducer = (namespace, actions) =>  (trackingContext) =>  (state, action) => { /* reducer stuff */ }

// in the namespacing function
export const namespaceComponent = (namespace) => {
  /* other namespace code */

  const namespacedActions = namespaceActions(actions);
  const reducer = unboundReducer(namespace, actions);

  /* other namespace code */
};

// initializing the component
const coolInstance = {
  ActualComponent: Component,
  initializedReducer: reducer('trackingId')
} = namespaceComponent('COOL');

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.

case 'HUE_BOX_INIT':
  return loop(
    {
      ...state,
      status: LOADING
    },

    // this starts a promise that retuns an action 
    Cmd.run(fetchHue,
      args: [pathToHue, namespacedActionToReturn]
    )
  );

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.


thanks to Logan McDonald_,_ Ryan Closner, and Will Duffy, for their help with this post & to David Peter and Will Duffy for investigating with me in the first place