How to Build a Super Simple React Component in a Phoenix App

Phoenix + JS

The Phoenix framework ships with Brunch, an easy-to-use asset build tool. If you're building fairly straightforward front-ends, Brunch works pretty much flawlessly out of the box. For more complex projects that involve custom configuration, you'd probably want a more robust asset build tool, such as Webpack.

For the Commits project I built, which surfaces the commit messages of a former colleague through a simple web interface, I did nearly nothing to get it to work. And it was awesome! So let's walk through what I did.

The Dependencies

First, if you open up the commits project, you'll notice that the assets are located within the assets directory. (Shocker, I know.)

To get Brunch to work with Babel, I cd'd into the assets directory and ran the command npm install --save-dev babel-brunch. Then, inside of that directory is a brunch-config.js file to which I added only the react and es2015 presets within the babel plugin configuration:

plugins: {
babel: {
    ignore: [/vendor/],
    presets: ['react', 'es2015']
  }
}

If you're unfamiliar with Babel, it's a JavaScript compiler that allows you to write JavaScript in different syntaxes, like ES6 or JSX. It'll turn your code into regular old JavaScript, so that browsers will be able to interpret it. By using different plugins, you'll be able to write different flavors of JavaScript. Modern browsers for the most part are supporting ES6 syntax now, but just for funsies and more browser compatibility, I kept that plugin.

Great, so from there, I added a few packages to my project.

npm install --save axios react babel-preset-react react-dom react-mousetrap

Axios is a Promise-based HTTP client for the browser. You could use the browser's native fetch API, but...why? Over at Flatiron School, we've had a long-running debate about which to use, and I've consistently been pro axios. But if you really want to go native, see these docs and keep resolving promises for the rest of your life.

React, babel-preset-react, and react-dom are all libraries that allow you to write React components in JSX and get them to render to the DOM.

React Mousetrap is a fun higher-order component that allows you to easily add keyboard shortcuts to your app.

The App Component

After that initial setup, we're all ready to write some JavaScript! For this particular project, the CSS wasn't that exciting, but if you really want to check it out, it's all in /assets/css/app.css. But anyway, here's the fun part.

The entry point to the app is /assets/js/app.js, which is the main script that gets included on the Phoenix layout template, /lib/commits_web/templates/layout/app.html.eex.

Inside that entry file, I import the required libraries and then render the main component inside a div with an id of app.

// assets/js/app.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/App'

ReactDOM.render(<App />, document.getElementById('app'))

What controls the message that gets displayed is the App component. To start, I wrote up a basic component that has its own local state. Note that I'm not using Redux at all in this app because the state is so simple, and to use Redux here would probably be overkill. A first pass of the code, with annotations, follows.

class App extends React.Component {
  constructor(props) {
    super(props)
    // Here I explicitly set the initial state of the component
    this.state = {
      loading: true,
      messages: [],
      currentMessageIndex: null,
    }
    // This is a common convention in React. What we're doing here is
    // returning a new function that has the right context for "this". Since
    // this function will be invoked in a callback later, we need to make sure
    // that "this" still refers to the `App` object.
    this.changeIndex = this.changeIndex.bind(this)
  }

  componentWillMount() {
    // This function comes from the mousetrap library
    // What I'm doing is invoking the function this.changeIndex as a
    // callback when the spacebar is pressed.
    this.props.bindShortcut('space', this.changeIndex)
  }

  componentDidMount() {
    // When the component mounts, I'm asking the backend to return ALL the
    // commit messages in the database. That may seem like a lot, but it
    // doesn't actually take that long, and this was simpler.
    axios.get('/api/commit_messages').then((data) => {
      // Oh gosh, yeah, this is how I structured the payload on the backend.
      const messages = data.data.data
      // Once the Promise resolves with the data, I put that data into the
      // state of the component and generate a random number from 0 to the
      // number of total messages
      this.setState({
        messages: messages,
        currentMessageIndex: this.generateRandomNumber(messages),
        loading: false
      })
    })
  }

  generateRandomNumber(messages) {
   return Math.floor(Math.random() * messages.length)
  }

  changeIndex() {
    // Whenever the spacebar is pressed or the text is clicked, the index
    // will reset so we can see a new message
    this.setState({
      currentMessageIndex: this.generateRandomNumber(this.state.messages),
    })
  }

  render() {
    // If there are no messages yet, I just return nothing
    if (this.state.loading) { return null }

    return(
      <div onClick={this.changeIndex}>
        <div>
          <h2>{ this.state.messages[this.state.currentMessageIndex].content }</h2>
        </div>
      </div>
    )
  }
  
  // At the bottom of the file I export the App component wrapped in the
  // higher-order mouseTrap component
  export default mouseTrap(App)

Then, to make things a little more splashy, I started picking a random color scheme that would also switch when the message switched. A little refactoring and reorganizing facilitated this. Instead of using CSS here, I'm putting inline styles right into JSX.

First, for simplicity, I defined a set of color schemes as a constant at the top of my file. Many of these I pulled from coolors.

const colorSchemes = [
  {
    backgroundColor: '#062F4F',
    headingColor: '#4ABDAC',
  },
  { backgroundColor: '#EA526F',
    headingColor: '#FCEADE'
  },
  { backgroundColor: '#171738',
    headingColor: '#DFF3EF'
  },
  { backgroundColor: '#272932',
    headingColor: '#72B01D'
  },
  { backgroundColor: '#72B01D',
    headingColor: '#001D4A'
  },
  { backgroundColor: '#103900',
    headingColor: '#0FFF95'
  },
  { backgroundColor: '#1D1E2C',
    headingColor: '#DDBDD5'
  },
  { backgroundColor: '#59656F',
    headingColor: '#F7EBEC'
  },
  { backgroundColor: '#292F36',
    headingColor: '#FF6B6B'
  },
  { backgroundColor: '#373F51',
    headingColor: '#DAA49A'
  },
  { backgroundColor: '#424242',
    headingColor: '#FCFC62'
  }
]

Next, I pretty much generated another random number that would serve as the color scheme index and also added a default value to initial state. To get the styling right for the entire container, I also introduced a new Main component that would be able to control the colors of the entire page while also correctly positioning the commit message in the center of the page. Since that Main component should also render when the messages are still loading, I decided to pass child props to that component to render. This way, when the messages are ready, I can render the selected message; otherwise, I can render a CSS loading animation. Here's what the code looked like after this changes.

import React from 'react'
import axios from 'axios'
import { mouseTrap } from 'react-mousetrap'

const colorSchemes = [
  {
    backgroundColor: '#062F4F',
    headingColor: '#4ABDAC',
  },
  { backgroundColor: '#EA526F',
    headingColor: '#FCEADE'
  },
  { backgroundColor: '#171738',
    headingColor: '#DFF3EF'
  },
  { backgroundColor: '#272932',
    headingColor: '#72B01D'
  },
  { backgroundColor: '#72B01D',
    headingColor: '#001D4A'
  },
  { backgroundColor: '#103900',
    headingColor: '#0FFF95'
  },
  { backgroundColor: '#1D1E2C',
    headingColor: '#DDBDD5'
  },
  { backgroundColor: '#59656F',
    headingColor: '#F7EBEC'
  },
  { backgroundColor: '#292F36',
    headingColor: '#FF6B6B'
  },
  { backgroundColor: '#373F51',
    headingColor: '#DAA49A'
  },
  { backgroundColor: '#424242',
    headingColor: '#FCFC62'
  }
]

class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      loading: true,
      messages: [],
      currentMessageIndex: null,
      colorSchemeIndex: 0
    }
    this.changeIndex = this.changeIndex.bind(this)
  }

  componentWillMount() {
    this.props.bindShortcut('space', this.changeIndex)
  }

  componentDidMount() {
    axios.get('/api/commit_messages').then((data) => {
      const messages = data.data.data
      this.setState({
        messages: messages,
        currentMessageIndex: this.generateRandomNumber(messages),
        loading: false
      })
    })
  }

  generateRandomNumber(messages) {
   return Math.floor(Math.random() * messages.length)
  }

  generateRandomColorSchemeIndex() {
    return Math.floor(Math.random() * colorSchemes.length)
  }

  changeIndex() {
    this.setState({
      currentMessageIndex: this.generateRandomNumber(this.state.messages),
      colorSchemeIndex: this.generateRandomColorSchemeIndex()
    })
  }

  render() {
    // Here I'm reading the index from state and finding the corresponding
    // colorScheme from the constant I defined at the top
    let backgroundColor = colorSchemes[this.state.colorSchemeIndex].backgroundColor
    let headingColor = colorSchemes[this.state.colorSchemeIndex].headingColor

    // Now, if the messages aren't ready yet, I'm jsut going to pop a
    // spinner on the page
    if (this.state.loading){
      return (
        <Main {...this.props}>
          <div className="spinner">
            <div className="rect1"></div>
            <div className="rect2"></div>
            <div className="rect3"></div>
            <div className="rect4"></div>
            <div className="rect5"></div>
          </div>
        </Main>
      )
    }

    // Otherwise, if everything's loaded let's go ahead and show a message
    return(
      <Main {...this.props} backgroundColor={backgroundColor}>
        <div className='level' onClick={this.changeIndex}>
          <div className='level__inner'>
            <h2 className='heading heading--level-1 util--text-align-c' style={{color: headingColor}}>{ this.state.messages[this.state.currentMessageIndex].content }</h2>
          </div>
        </div>
      </Main>
    )
  }
}

// This Main component is a functional component because it doesn't have its
// own local state and we have no real use of lifecycle methods here. It
// takes simply props as its argument
const Main = (props) => {
  return(
    <main className='body' role='main' style={{backgroundColor: props.backgroundColor}}>
      <div className='flex-container'>
        <div className='flex__item'>
          <div className='level level--padding-short'>
            <div className='level__inner'>
              <h1 className='heading heading--level-2 util--text-align-c'>Commits by Logan</h1>
            </div>
          </div>
        </div>
        <div className='flex__item'>
          {props.children}
        </div>
        <div className='flex__item'>
          <div className='level'>
            <div className='level__inner'>
              <h3 className='heading heading--level-3 util--text-align-c'>Press the spacebar or tap the text to get a new message</h3>
            </div>
          </div>
        </div>
      </div>
    </main>
  )
}

// Again, we export the wrapped version of the App component
export default mouseTrap(App)

And that was it! Just a simple React component that responds to spacebar presses and also renders some hilarious commit messages. See it in action here.

Resources