← til

From class components to function components with hooks

February 19, 2019
react

I've been using React 16.5.2 context instead of Redux in the super secret app I'm working on, and I appreciate the simplicity improvement. The learning curve is pretty minimal, and there are fewer levels of abstraction. The one thing that was annoying me was the non-optimal ergonomics of the API when consuming the context.

The first thing that was annoying was the standard consumer wrap:

import React, { Component, createContext } from 'react'

const timerContext = createContext()

class Provider extends Component {
  constructor() {
    super()
    this.state = {
      store: {
        active: false,
        stop: this.stop.bind(this)
      },
    }
  }

  start() {
    let { store } = this.state
    store = { ...store, active: true }
    this.setState({ store })
  }

  stop() {
    let { store } = this.state
    store = { ...store, active: false }
    this.setState({ store })
  }

  render() {
    return (
      <timerContext.Provider value={this.state}>
        {this.props.children}
      </timerContext.Provider>
    )
  }
}

class Timer extends Component {
  constructor () {
    super()
    this.state = {
      activeTab: 'timer'
    }
  }

  buttonClick (event) {
    const { store } = this.props.context

    if (store.active) {
      store.stop()
    } else {
      store.start()
    }

    event.stopPropagation()
  }

  changeTab (value) {
    this.setState({ activeTab: value })
  }

  render () {
    const { store } = this.props.context

    return (
      <div>
        <ul>
          <li active={this.state.activeTab === 'timer'}
               onClick={this.changeTab.bind(this, 'timer')}>
               Timer
          </li>
          <li active={this.state.activeTab === 'manual'} 
               onClick={this.changeTab.bind(this, 'manual')}>
               Manual
          </li>
        </ul>

        <button active={store.active}
                onClick={(event) => { this.buttonClick(event) }}>
          {store.active ? 'Stop' : 'Start' }
        </button>
      </div>
    )
  }
}

// Not an actual layout, provider and consumer 
// are not that near each other.
class Layout extends Component {
  render() {
    return (
     <Provider>
       { /* This is the annoying part. */ }
       <timerContext.Consumer>
         {({ store }) => (
           <Timer store={store}/>
         )}
       </timerContext.Consumer>
     </Provider>
    )
  }
}

The second thing that was annoying was the fact that I had to wrap the consumer wrap in a higher-order component, to be able to use the context outside a render:

import React, { Component, createContext } from 'react'

// Our good old context.
const timerContext = createContext()

class Provider extends Component {
  constructor() {
    super()
    this.state = {
      store: {
        active: false,
        stop: this.stop.bind(this)
      },
    }
  }

  start() {
    let { store } = this.state
    store = { ...store, active: true }
    this.setState({ store })
  }

  stop() {
    let { store } = this.state
    store = { ...store, active: false }
    this.setState({ store })
  }

  render() {
    return (
      <timerContext.Provider value={this.state}>
        {this.props.children}
      </timerContext.Provider>
    )
  }
}

class Timer extends Component {
  constructor () {
    super()
    this.state = {
      activeTab: 'timer'
    }
  }

  buttonClick (event) {
    const { store } = this.props.context

    if (store.active) {
      store.stop()
    } else {
      store.start()
    }

    event.stopPropagation()
  }

  changeTab (value) {
    this.setState({ activeTab: value })
  }

  render () {
    const { store } = this.props.context

    return (
      <div>
        <ul>
          <li active={this.state.activeTab === 'timer'}
               onClick={this.changeTab.bind(this, 'timer')}>
               Timer
          </li>
          <li active={this.state.activeTab === 'manual'} 
               onClick={this.changeTab.bind(this, 'manual')}>
               Manual
          </li>
        </ul>

        <button active={store.active}
                onClick={(event) => { this.buttonClick(event) }}>
          {store.active ? 'Stop' : 'Start' }
        </button>
      </div>
    )
  }
}

// HOC we're introducing just to
// allow usage of context outside of render.
// Not great.
const withContext = (Component) => {
  return (props) => (
    <timerContext.Consumer>
      {(context) => (
        <Component {...props} context={context}/>
      )}
    </timerContext.Consumer>
  )
}


// The Timer component is wrapped in HOC now.
const TimerWithContext = withContext(class Timer extends Component {
  constructor () {
    super()
    this.state = {
      activeTab: 'timer'
    }
  }

  buttonClick (event) {
    const { store } = this.props.context

    if (store.active) {
      store.stop()
    } else {
      store.start()
    }

    event.stopPropagation()
  }

  changeTab (value) {
    this.setState({ activeTab: value })
  }

  render () {
    const { store } = this.props.context

    return (
      <div>
        <ul>
          <li active={this.state.activeTab === 'timer'}
               onClick={this.changeTab.bind(this, 'timer')}>
               Timer
          </li>
          <li active={this.state.activeTab === 'manual'} 
               onClick={this.changeTab.bind(this, 'manual')}>
               Manual
          </li>
        </ul>

        <button active={store.active}
                onClick={(event) => { this.buttonClick(event) }}>
          {store.active ? 'Stop' : 'Start' }
        </button>
      </div>
    )
  }
})

class Layout extends Component {
  render() {
    return (
     <Provider>
       { /* No longer that annoying, but we've introduced HOC. */ }
       <TimerWithContext />
     </Provider>
    )
  }
}

I've managed to get rid of those annoyances by upgrading to the latest React16.8.2, converting my class components to function components and replacing my nasty HOC with a simple hook.

Here's the result:

import React, { useContext, useState, Component, createContext } from 'react'

// The context yet again.
// Boring.
const timerContext = createContext()

class Provider extends Component {
  constructor() {
    super()
    this.state = {
      store: {
        active: false,
        stop: this.stop.bind(this)
      },
    }
  }

  start() {
    let { store } = this.state
    store = { ...store, active: true }
    this.setState({ store })
  }

  stop() {
    let { store } = this.state
    store = { ...store, active: false }
    this.setState({ store })
  }

  render() {
    return (
      <timerContext.Provider value={this.state}>
        {this.props.children}
      </timerContext.Provider>
    )
  }
}

// The Timer class has been converted to function.
function Timer (props) {
  const { store } = useContext(timerContext)

  const [tab, setTab] = useState('timer')

  const buttonClick = (event) => {
    if (store.active) {
      store.stop()
    } else {
      store.start()
    }

    event.stopPropagation()
  }

  return (
    <div>
      <ul>
        <li active={tab === 'timer'} 
             onClick={() => setTab('timer')}>
             Timer
        </li>
        <li active={tab === 'manual'}
             onClick={() => setTab('manual')}>
             Manual
        </li>
      </ul>

      <button active={store.active} 
              onClick={(event) => { buttonClick(event) }}>
        {store.active ? 'Stop' : 'Start' }
      </button>
    </div>
  )
}

class Layout extends Component {
  render() {
    return (
     <Provider>
       { /* Very slick now */ }
       <Timer />
     </Provider>
    )
  }
}

Nothing is better than removing code, so this is a win. Upgrading to a newer version of React was worth it.