Replacing Mixins in React

If you want to run the code, here's a repo to follow along with

If you work on a project that adopted React early on, you might have some components written with the soon to be deprecated React.createClass. Also, if you've created component libraries using createClass you could be dependent on APIs that are not supported when extending React.Component. A common case of this is mixins.

If you're not familiar with mixins, this write up explains some of the early choices made for mixins and why they moved away from them.

Here's a simple mixin example -

import React from 'react'

const someMixin = {  
  getInitialState() {
    return {
      newNumber: 1
    }
  },

  setNewNumber(num) {
    this.setState({
      newNumber: num
    })
  }
}

const someOtherMixin = {  
  someMethod(number) {
    console.log(number)
  }
}

const Composer = React.createClass({

  mixins: [
    someMixin,
    someOtherMixin
  ],

  render() {
    return <p>{this.state.newNumber}</p>
  },

  someStaticMethod(num) {
    this.setNewNumber(num)
    this.someMethod(num)
  }
})

class App extends React.Component {  
  constructor(props) {
    super(props)
    this.callChildMethod = this.callChildMethod.bind(this)
  }

  callChildMethod() {
    this.refs.composer.someStaticMethod(5)
  }

  render() {
    return (
      <div>
        <button onClick={this.callChildMethod}></button>
        <Composer ref="composer" />
      </div>
    )
  }
}

Here we have two mixins that do simple things that can work together. One sets the state for newNumber in components and the other lets you log out anything you pass into it.

Mixins can be replaced with high order components or decorators(with a special babel transform). They both essentially do the same thing and are different from mixins in a few key ways.

  • Mixins extend the class they are mixed into, while HOCs compose classes and return new classes.

  • Mixins can setState in components since they are going to be part of that component. HOCs should not setState since they are only functions that take a class and return another class, they communicate through props instead.

Because of the difference in these approaches, we'll have to change our API to reference this.props.newNumber instead of this.state.newNumber in the Composer component. Otherwise we should be able to keep our API intact.

Here's what the HOC version can look like -

import React, { Component } from 'react'

const someDecorator = WrappedComponent => {  
  class Wrapper extends Component {
  static displayName = `Wrapper(${WrappedComponent.displayName})`

    constructor(props) {
      super(props)
      this.state = { newNumber: 1 }
      this.setNewNumber = this.setNewNumber.bind(this)
    }

    setNewNumber(num) {
      this.setState({
        newNumber: num
      })
    }

    render() {
      const props = Object.assign(
        {},
        ...this.props,
        {newNumber: this.state.newNumber},
        {setNewNumber: this.setNewNumber}
      )
      return <WrappedComponent ref="child" {...props} />
    }
  }

  return Wrapper
}

const someOtherDecorator = WrappedComponent => {  
  class Wrapper extends Component {
  static displayName = `Wrapper(${WrappedComponent.displayName})`

    constructor(props) {
      super(props)
      this.someMethod = this.someMethod.bind(this)
    }

    someMethod(num) {
      console.log(num)
    }

    render() {
      return <WrappedComponent ref="child" {...this.props} someMethod={ this.someMethod } />
    }
  }

  return Wrapper
}

class Composer extends React.Component {  
  constructor(props) {
    super(props)
    this.someStaticMethod = this.someStaticMethod.bind(this)
  }

  render() {
    return <p>{this.props.newNumber}</p>
  }

  someStaticMethod(num) {
    this.props.setNewNumber(num)
    this.props.someMethod(num)
  }
}

const WrappedComposer = someDecorator(someOtherDecorator(Composer))

class App extends React.Component {  
  constructor(props) {
    super(props)
    this.callChildMethod = this.callChildMethod.bind(this)
  }

  callChildMethod() {
    this.refs.composer.someStaticMethod(5)
  }

  render() {
    return (
      <div>
        <button onClick={this.callChildMethod}></button>
        <WrappedComposer ref="composer" />
      </div>
    )
  }
}

The main difference is that where ever we used to access properties from our mixin on this, we now have to access them at this.props. Not too shabby, but if you've tried this out then you know that there's a bug.

TypeError: this.refs.composer.someStaticMethod is not a function

If we log out this from App.callChildMethod, we can see what's going on and it makes perfect sense.

Since we've now wrapped our original component in 2 HOCs, we have to dig in several nested levels of refs in order to reach our function in the Composer class. This is a huge limitation because an HOC has no way of knowing how many levels deep the intended class is going to be at, that part is totally up to the consumer of the HOC.

We need to solve this in a way that allows us to ignore the nesting that comes with HOCs. We can do this by hoisting our Composer component's methods up at each level of the HOC with a reference to the original ref each time.

If we do this correctly, every time we compose a component we then pass up the methods we plan to expose and when the final consumer of the component calls them, they will be available at the top level.

Luckily for us, there's already a package that does exactly this, hoist-non-react-methods. This package ignores built in React methods and hoists only user defined methods from a sourceComponent to a targetComponent. So our changes are minimal -

npm install hoist-non-react-methods --save  
import hoistNonReactMethods from 'hoist-non-react-methods'  
...
return hoistNonReactMethods(Wrapper, WrappedComponent)  
  // return Wrapper

We need to install and import the package, and then call hoistNonReactMethods with both components.

If you followed the ref="child" naming convention that I did then you're done. If you named your ref something else, then just add a function to reference it as the third argument like this hoistNonReactMethods(Wrapper, WrappedComponent, (c) => c.refs.something

If you solved this problem a different way or have any questions, feel free to comment or reach out on twitter @stides303