React, Redux, Recompose and some simple steps to remove "some" boilerplate and improve reuse

Most of the documentation around react+redux centers around using pure components and redux.connect to connect up the pieces. However, sometimes you may want some very local state that is not really application state and its just easier to use the local react state. In other words, you need to wrap a pure component, keep it pure, but still somehow, wrap some local state around it.
For example, I want to create a table showing a list and I want to highlight the hover row. There are many ways to do this, for example using CSS, but another way to do this is to use some local state to store the "hovered" row. Hovering is an intense activity and is not critical for communicating with other UI components or business logic. We could whip out react-ui which provides a way to manage block-scoped state. For our case though we just want to highlight the moused-over row in a list and there are no other connections or functionality that is tied to the "hovered" item in the list so local react state is fine. I selected this approach vs using css :hover to show an example where it makes sense to use state that is local to the component but unimportant to the rest of the application.
Using redux.connect makes sense but the state provided to the mapPropsToState is the global state, not the local react component state so we cannot use redux.connect to solve the hover problem. We can use redux.connect to connect our component up to the global redux store and we still need to that for the other behavior and state aspects of the table.
Let's use react-virtualized to create a table that lists notes. The notes have a subject, some note text and optionally an attached document and a document size. The notes are shown in last-modified order (although that's not important for us). Our selection requirements are:
  • Clicking on a row selects that row and it should be highlighted with a dark blue background.
  • Hovering highlights the hovered row with a light blue line to improve the user experience.
  • When a row is selected, the note content (separate from the document) should show below.
Note: The component that shows the note content is a simple text box separate from the table. We'll not show that in this blog. We need to set the redux store's selectedNoteIndex state value. This will force the note to show in another box on the web page. The picture below shows Note 5 selected and note 1 hovered. Note 1 had been edited after notes 2 and 3. Since Note 5 is selected, it is shown in a box that is not shown in the picture.
Let's first write a component using react-redux and connect but lacking the "hover" feature:
function mapStateToProps(state) {
    return {
 rowCount: state.notes.items.length,
 rowGetter: ({index}) => state.notes.items[index],
 rowClassName: ({index}) => {
     if(index != -1 && index === state.notes.selectedIndex)
  return "selectedNoteRow";
     return null;
 }
    }
}

function mapDispatchToProps(dispatch) {
    return {
 onRowClick: ({index}) => dispatch(actions.selectedNoteIndex(index))
    }
}

let NotesSummary1 = ({rowGetter, onRowClick, rowCount, rowClassName, ...rest}) => {
    return <Table
        width={500}
        height={150}
        rowHeight={20}
        headerHeight={30}
        // We don't need to do this assuming they are included in ...rest
        // but we broke them out to show destructuring explicitly.
        rowGetter={rowGetter}
        rowClassName={rowClassName}
        onRowClick={onRowClick}
        rowCount={rowCount}
        {...rest}
    >
 <Column label='Subject' dataKey="subject" flexGrow={1}
  cellRenderer={ vals => renderSubject({...vals, ...rest}) }
  width={100}/>
 <Column label='Filename' dataKey="filename" flexGrow={1} width={50}/>
 <Column label='Filesize' dataKey="filesize"
  cellRenderer={ renderFilesize }
  width={100}/>
    </Table>; 
}
export default connect(mapStateToProps, mapDispatchToProps)(NotesSummary1)
The details behind renderFilesize, renderSubject and the redux actions are not important--they turn a value into a link and string for display purposes. The approach above is the standard approach using react+redux with connect.
We need to add the hover affect. Since the hover is not important to the overall applictaion, we do not need to store which row is being hovered in redux. To add hover to a react-virtualized table we can use Table's onRowMouseOver. We do not want to add the function to mapDispatchToProps because we do not need to dispatch it to the redux store. We could, but there is no reason to expose it even to mapDispatchToProps and we want to reuse existing logic without changing it.
To add very local, react "standard" state, we can use the "class" approach and provide the local state. In the code below we reuse mapStateToProps and mapDispatchToProps since redux.connect is just syntatic sugar that integrates these functions. In fact, we will be very explicity about reusing the map* functions.
First we will define a superclass for our component that handle the standard redux boilerplate around redux store subscriptions. We can easily make a class that implements the subscription function and classes that need the React.Component machinery can inherit the redux parts. After all, the redux.connect is just a bit of syntax sugar for the class pattern.
/**
 * Class based approach to managing boilerplate. You can only
 * take inheritance so far though.
 */           
class ReduxComponent extends React.Component {

    constructor(props) {
 super(props);
    }
    
    componentDidMount() {
 this.unsubscribe = this.context.store.subscribe(() => this.forceUpdate());
    }

    componentWillUnmount() {
 this.unsubscribe();
    }
}
ReduxComponent.contextTypes = {
    store: PropTypes.object
}
We should have used the prop type from redux for declaring the property type of the store attribute, but we were lazy and just declared it as an object.
Now we can reuse our map* functions and subclass from the ReduxComponent class:
export default class NotesSummary2 extends ReduxComponent {

    constructor(props) {
 super(props);
 this.state = { hoveredRowIndex: -1 }
    }
    
    // Our render just grabs the redux store and state then calls
    // the map* functions to create the values needed for rendering.
    // This shows how easy it is to refactor the code and how the
    // react-redux composes using connect().
    render() {
 const rstate = this.context.store.getState();
 const sprops = mapStateToProps(rstate);
 const dprops = mapDispatchToProps(this.context.store.dispatch);
 return this.doit({...sprops, ...dprops, ...this.props});
    }
    
    // We broke this out to another function to show that it was cut and paste.
    doit({rowGetter, onRowClick, rowCount, rowClassName, ...rest}) {
 return <Table
     width={500}
     height={150}
     rowHeight={20}
     headerHeight={30}
     rowGetter={rowGetter}
     rowClassName={({index}) => {
      if(index != -1 && index === this.state.hoveredRowIndex) return 'hoverNoteRow';
      return rowClassName({index});
         }}
     onRowClick={onRowClick}
     rowCount={rowCount}
     onRowMouseOver={({index, rowData}) => {
      console.log("MOUSE OVER: " + index  + ", " + rowData);
      this.setState({hoveredRowIndex: index});
         }}
        >
    <Column label='Subject' dataKey="subject" flexGrow={1}
     cellRenderer={ vals => renderSubject({...vals, ...rest}) }
     width={100}/>
    <Column label='Filename' dataKey="filename" flexGrow={1} width={50}/>
    <Column label='Filesize' dataKey="filesize"
     cellRenderer={ renderFilesize }
     width={100}/>
 </Table>;
    }
}
This works. We can now move the mouse around and have hover because we use the hoveredRowIndex local react state for the component and when the hover happens, we set the state which forces a render. All very straight forward.
We really need two things to add the hovering highlight:
  • Provide on onRowMouseOver method that set the new state based on the hovered row. This forces a render to be scheduled.
  • Add logic to modify the row class name so that it was set to the hover CSS class instead of the selected row CSS class. The CSS value for the selected row is set in the mapStateToProps and we do not really want to modify that function to accomodate setting the classname for hovering.
Can we do better? Or at last differently?
We can use a higher order component (HOC). A HOC is more abstract than the straight class approach above. For this example is also a bit overkill, but lets use it. We need to inject some local state into a pure component. And we want to reuse existing functions as much as possible. Hence, we need to compose functions. Instead of dependency injection or classes to compose, we will use higher order functions.
See https://github.com/acdlite/recompose for the HOC way to do this. Hipsters recompose. Recompose helps you use a very functional style and compose via functions vs classes (of course, classes are just functions :-)).
First, let's think about what we need:
  • We need to add some state for the index that is being hovered.
  • The classname needs to be set based on values from the hover index and the selected index.
Based recompose, we need at least some state, so let's use withState:
 withState('hoveredRowIndex', 'updateHoveredRowIndex', -1)
withState returns a function that can be applied to NotesSummary1. This function returns a component that has two properties added to it when the original NotesSummary1 component is mounted: the "state", hoveredRowIndex, and the state update function, updateHoveredRowIndex. Since NotesSummary1 has a ...rest parameter we can pass in properties intended for the Table and have them override other pre-existing properties. NotesSummary1 does not destructure the two new properties hoveredRowIndex or updateHoveredRowIndex since it did not know about them when the component was written. Overriding with rest is a standard approach:
let NotesSummary1 = ({ rowGetter, onRowClick, rowCount, rowClassName, ...rest}) => {
  //...
}
First, let's just make sure we can tap into the onRowMouseOver property on the Table component:
let enhanceWithHover = compose(
    withState('hoveredRowIndex', 'updateHoveredRowIndex', -1),
    withHandlers({
 onRowMouseOver: ({updateHoveredRowIndex}) => ({index, rowData}) => {
     console.log("MOUSE OVER: " + index  + ", " + rowData);
     updateHoveredRowIndex(index);
 } 
    }));

export default connect(mapStateToProps, mapDispatchToProps)(enhanceWithHover(NotesSummary1))
If we put this into use, we see that console log prints out many "MOUSE OVER" messages showing that our recompose-based solution is starting to work. onRowMouseOver defined in withHandlers is a function that returns a function. When the base component is enhanced, the enhanced component will receive the properties originally intended to be passed to the base component. The enhanced component will also receive two values defined by withState as well the other properties passed to it from the component that wraps the final enhanced component. onRowMouseOver in react-virtualized's Table takes a function that returns a classname or a hard coded classname string. onRowMouseOver has multiple values passed to it per it's API. We take two of those values, the index of the row that is being hovered and the rowData, the raw data for that row. We extract them via destructing. We then use them to update the local react state.
The recompose machine makes sure to call our withHandlers's onRowMouseOver with the properties filtering down from the "outer component" then assigns the base component's property onRowMouseOver with the resulting function, per the react-virtualized API. The final function takes an index and rowData to update the local HOC's hoveredRowIndex state. It's a closure.
compose composes two functions withState and withHandlers togethe. Once applied to NotesSummary1, we have our enhanced component. For this module, it is default exported and used in the overall application. The ES6 class-based approach is less abstract and easier to read for some. However, recompose allowed us to smash together components that we may not otherwise be able to modify for various reasons skip creating a class. Is this better?
This level of functional composition can be very difficult for some people to understand. We can apply our enhancer to any other pure react component without coding up a class to wrap or inherit from. We can compose with one function call. We could have made a class like we did above but then we would be stuck always creating another class to wrap the target. The one-function-composition approach is compelling if we had to enhance many other components.
We need to tie the updating hover index state that into classname changes.
The logic in the class that worked earlier is:
     rowClassName={({index}) => {
      if(index != -1 && index === this.state.hoveredRowIndex) return 'hoverNoteRow';
      return rowClassName({index});
         }}
The method uses a combination of information from the parameters, rowClassName, as passed in through the props. rowClassName as passed into the render function comes from our mapStateToProp where it uses the redux state to determine if the row is selected. We would like to reuse that logic and overlay some additional logic. Fortunately, withStatepasses in the hoveredRowindex through the props and we know that mapStateToProp passes in the rowClassName function we would like to reuse.
So let's try:
let enhanceWithHover = compose(
    withState('hoveredRowIndex', 'updateHoveredRowIndex', -1),
    withHandlers({
 onRowMouseOver: ({updateHoveredRowIndex}) => ({index, rowData}) => {
     //console.log("MOUSE OVER: " + index  + ", " + rowData);
     updateHoveredRowIndex(index);
 },
 rowClassName: ({rowClassName, hoveredRowIndex}) => ({index}) => {
     console.log("Determining rowClassName");
     if(index != -1 && index === hoveredRowIndex) return 'hoverNoteRow';
     return rowClassName({index});
 }
    }));
export default enhanceWithHover(connect(mapStateToProps, mapDispatchToProps)(Notes1Summary))
Notice that the enhanced component is returned from the connect call which is different than what I did above. When I first wrote this version of the enhancer, I enhanced the component as the outer composition. enhancedWithHover is the outer call in the above code. This change causes problems. The hover does not work.
Our mapStateToProps creates a rowClassName and the function call mapStateToProps is closer to the base component we are composing with. Hence, any rowClassName that we provide in the outer composition is "overwritten" by mapStateToProps's rowClassName.
Let's try moving mapStateToProps outside the connect call and into our compose. It becomes ackward quickly:
let enhanceWithHover = compose(
    // Add in the hover local react state.
    withState('hoveredRowIndex', 'updateHoveredRowIndex', -1),
    // Grab the redux store from the context.
    getContext({store: PropTypes.object}),
    // Use the store to call mapStateToProps earlier than Redux.connect.
    withProps(({store}) => mapStateToProps(store.getState())),
    // Create handlers for changing the CSS class when mouse over changes
    // the currently moused row. When a mouse over happens, update the
    // hover state "added" in earlier.
    withHandlers({
 rowClassName: ({rowClassName, hoveredRowIndex}) => ({index}) => {
     if(index != -1 && index === hoveredRowIndex) return 'hoverNoteRow';
     return rowClassName({index});
        },
 onRowMouseOver: ({updateHoveredRowIndex}) => ({index, rowData}) => {
     updateHoveredRowIndex(index);
 }
    })
);
export default enhanceWithHover(connect(mapStateToProps, mapDispatchToProps)(Notes1Summary))
This does not work because we have an "update" problem.
When we use redux.connect, the connect sets up the base component to render when the redux state changes. The above HOC does not run when the redux store updates itself. The outer withState knows nothing about redux rendering processing. The outer component is not told to render when the notes are fetched from the server and the redux state updated with the list of notes. Since the notes fetch never forces the component to update, no rows are ever shown. Without rows, the mouse-over never fires to force the HOC component to re-render through the wrapped component hierarchy. Catch-22. We could add some trickery to the above to render if the redux state changes, but redux already calculates this for us. We need to do better.
Clearly, mapStateToProps is not well written. There is a better way to write this component of course but I am assuming I cannot change the original component and I have to solve this problem using a non-code changing approach.
We know we need to move the creation of the rowClassName function further outside so that our withHandlers override of rowClassName is used instead of the rowClassName in mapStateToProps and we still need access to the original rowClassName so we can reuse the logic. Let's rework the compose and the connect call:
// Compose left to right.
let enhanceWithHover = compose(
    withState('hoveredRowIndex', 'updateHoveredRowIndex', -1),
    withHandlers({
 rowClassName: ({rowClassName, hoveredRowIndex}) => ({index}) => {
     if(index != -1 && index === hoveredRfunctional compositionowIndex) return 'hoverNoteRow';
     return rowClassName({index});
        },
 onRowMouseOver: ({updateHoveredRowIndex}) => ({index, rowData}) => {
     updateHoveredRowIndex(index);
 }
    })
);
export default connect(mapStateToProps, mapDispatchToProps)(enhanceWithHover(NotesSummary1))
This worked. The rowClassName function from mapStateToProps is the furthest outside the composition so the enhancer's version can override it. And we get the rendering to occur at the outermost composed component.
The composition included the following HOC from recompose:
  • compose: Composes the HOCs left to right. The result of compose must be applied to the inner component to "wrap" them.
  • withState: This adds new state properties as props that are eventually passed to the component.
  • withHandlers: Adds handlers to the to the props passed to the inner component. The handlers are added in a way that keeps updates minimized somewhat.
  • connect: First we enhance the base component and then wrap the connect around it. This ensures that our enhanced component is re-rendered when the redux state is updated, which only connect does.
We used a functional composition vs the class approach. We reused logic, although poorly architected logic, that we knew was already present. To achieve this reuse we had to use multiple, more abstract, constructs. This level of abstraction may or may not be an acceptable trade-off.
That's it!

Comments

Popular posts from this blog

quick note on scala.js, react hooks, monix, auth

zio environment and modules pattern: zio, scala.js, react, query management

user experience, scala.js, cats-effect, IO