Building an Elixir Umbrella App with Phoenix and React: Part III


Building a Phoenix 1.3 with React + Redux App

You can check out parts I and II here and here.

Building the Phoenix Child App

Our Phoenix app will be pretty simple. It will take in a list of song ISRCs, rely on the Deliveries app to make a request to the YouTube API, and display results to the user.

We'll want the latest and greatest Phoenix version before we generate our new app:

$ mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez

Now, from within the apps/ directory:

$ mix pheonix new ytsr-status --no-ecto --no-brunch

Note that we generated an app without a database, since we don't need a persistence layer to simply take in a list of ISRCs, pass them to the YouTube API, and report the result. We've also skipped using brunch here, since we'll go with Webpack to configure our React app.

Running the App

Configuring Child Dependencies

The Phoenix app is the main driver of our umbrella app. It's the Phoenix app that calls on the Deliveries app to query YouTube. So, we'll start our umbrella app from the top-level via mix phoenix.start.

However, our Phoenix child app relies on its sibling, the Deliveries app. So we need to tell our Phoenix app that its sibling is one of its dependencies.

We'll add the following dependency to our list of dependencies in the deps function of apps/ytsr_status/mix.exs

{:deliveries, in_umbrella: true}

In this same file, we'll tell our Phoenix app to start up the Deliveries application when it starts up:

def application do  
  [mod: {YtsrStatus.Application, []},
   extra_applications: [:logger, :runtime_tools, :deliveries]]
end  

Setting up Phoenix

First, we'll update the app.html.eex layout template to contain a div to which we will append our React app.

# apps/ytsr_status/lib/ytsr_status/web/templates/layout/app.html.eex
...
<body>  
  <main id="main_container" role="main"></main>
</body>  
</html>  

Now we're ready to set up our React app.

Configuring React as our Phoenix Front-End

First things first, create a package.json

# apps/ytsr_status/
$ npm init

Our package.json should specify the entry point of our application, which will be the app.js file, along with the following dependencies:

apps/ytsr_status/package.json

{
  "name": "ytsr_status",
  "version": "1.0.0",
  "description": "check status of ISRCs with YouTube API",
  "main": "app.js",
  "directories": {
    "test": "test"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel-cli": "^6.24.1",
    "babel-core": "^6.24.1",
    "babel-loader": "^7.0.0",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1",
    "babel-preset-stage-2": "^6.24.1",
    "css-loader": "^0.28.1",
    "extract-text-webpack-plugin": "^2.1.0",
    "node-libs-browser": "^2.0.0",
    "node-sass": "^4.5.3",
    "sass-loader": "^6.0.5",
    "style-loader": "^0.17.0",
    "webpack": "^2.5.1"
  },
  "dependencies": {
    "babel-loader": "^7.1.0",
    "classnames": "^2.2.5",
    "es2015": "0.0.0",
    "es6-promise": "^4.1.0",
    "history": "^4.6.1",
    "invariant": "^2.2.2",
    "isomorphic-fetch": "^2.2.1",
    "prop-types": "^15.5.10",
    "react": "^15.5.4",
    "react-bootstrap": "^0.31.0",
    "react-dom": "^15.5.4",
    "react-redux": "^4.4.6",
    "react-router": "4.1.1",
    "react-router-dom": "^4.0.0-beta.8",
    "react-router-redux": "^5.0.0-alpha.6",
    "redux": "^3.6.0",
    "redux-logger": "^3.0.6",
    "redux-simple-router": "^2.0.4",
    "redux-thunk": "^2.2.0"
  }
}

Install your deps via npm install.

Now we're ready to configure Webpack.

Phoenix, React and Webpack 2

We'll create our Webpack config file:

apps/ytsr_status/webpack.config.js  

This file is responsible for a number of things:

  • Defining the output file:
...
var config = module.exports = {  
  entry: web("js/app.js"),
  output: {
    path: join('priv/static/js'),
    filename: "app.js"
  },
...

This loads the compiled React app from its entry point, js/app.js, and injects into the output file, priv/static/js/app.js. The output file is the location from which our Phoenix app will loads our React app.

  • Telling our app where to find the node_modules dependencies and how to load them:
...
resolveLoader: {  
    modules: [path.join(__dirname, 'node_modules')]
  },
  resolve: {
    extensions: ['.js', '.sass', '.css'],
    modules: ['apps/ytsr_status/node_modules']
  }
...
  • Configuring Babel Loader to transpile ES6
  • Configuring the ExtractTextPlugin to properly load .css and .sass files.

Here's a look at the whole configuration:

'use strict';

var path = require('path');  
var ExtractTextPlugin = require('extract-text-webpack-plugin');  
var webpack = require('webpack');

function join(dest) { return path.resolve(__dirname, dest); }

function web(dest) { return join('lib/ytsr_status/web/static/' + dest); }

var config = module.exports = {  
  entry: web("js/app.js"),
  output: {
    path: join('priv/static/js'),
    filename: "app.js"
  },
  resolveLoader: {
    modules: [path.join(__dirname, 'node_modules')]
  },
  resolve: {
    extensions: ['.js', '.sass', '.css'],
    modules: ['apps/ytsr_status/node_modules']
  },

  module: {
    noParse: /vendor\/phoenix/,
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          cacheDirectory: true,
          plugins: ['transform-decorators-legacy'],
          presets: ['react', 'es2015', 'stage-2', 'stage-0'],
        },
      },
      {
        test: /\.sass$/,
        loader: ExtractTextPlugin.extract({fallbackLoader: 'style', loader: 'css!sass?indentedSyntax&includePaths[]=' + __dirname +  '/node_modules'})
      },
      {
        test: /\.css$/,
        use: [ 'style-loader', 'css-loader' ]
      }
    ],
  },

  plugins: [
    new ExtractTextPlugin('./web/static/css/application.css'),
  ],
};

if (process.env.NODE_ENV === 'production') {  
  config.plugins.push(
    new webpack.optimize.DedupePlugin(),
    new webpack.optimize.UglifyJsPlugin({ minimize: true })
  );
}

Lastly, we have to tell our Phoenix app where to find our Webpack config and how to run it.

In /apps/ytsr_status/config/dev.exs we need to add the following:

use Mix.Config

config :ytsr_status, YtsrStatus.Web.Endpoint,  
  ...
  watchers: [node: ["apps/ytsr_status/node_modules/webpack/bin/webpack.js", "--watch", "--color", "--config", "apps/ytsr_status/webpack.config.js"]]

...

Now we're ready to build a simple React + Redux front-end!

Building the React + Redux Front-End

Application Architecture

Before we look at any code, let's take step back and describe the overall structure and flow of our React application.

Our React app will contain a form to submit ISRCs to our as-yet-to-be-built Phoenix API, and an area to display the results of this request.

These two responsibilities will be encapsulated in components, which are in turn contained by a main parent component.

The form component will dispatch an action that is responsible for making the API request and updating our application's state, causing our result-displaying component to re-render with the new results.

Here's a diagram of the overall structure:

Let's build it!

The React App's Entry Point

In our Webpack config, we specified that the entry-point of our React would be js/app.js. So, we'll build our our apps/ytsr_status/lib/ytsr_status/web/static/js/app.js file to inject our React app onto the page.

import React                            from 'react';  
import ReactDOM                         from 'react-dom';  
import { createStore, applyMiddleware } from 'redux'  
import { Provider}                      from 'react-redux'  
import thunk                            from 'redux-thunk'

import '../css/application.css'  
import App from './components/containers/app'


import rootReducer from './reducers'

const store = createStore(  
  rootReducer,
  applyMiddleware(thunk)
)


ReactDOM.render(  
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('main_container')
);

Let's break this down. In the above file we:

  • Import necessary modules from React and Redux, including the Thunk middleware that allows for us to build asynchronous actions.
  • Import our reducer (coming soon)
  • Create the store by invoking Redux's createStore function
  • Import our main container component, App (coming soon)
  • Use the React-Redux Provider to wrap our component tree, contained in the App component, and pass in our store. This makes the store available to our component tree.
  • Use ReactDOM to append our component tree to the page, via the #main_container element that we placed on our layout template earlier.

The Reducer

Our reducer will define application state. Our app is pretty simple: take in list of distribution ISRCs, pass that list to YouTube, indicate whether a given distribution was present in YouTube. So, our application state will simply track these distributions in the following format:

{
  distributions: 
   {
      distributions: [], 
      errors: null
   }
}

Consequently, we'll just have one reducer, the distributions reducer:

# /web/static/js/reducers/distributions.js

const initialState = {  
  errors: null,
  distributions: []
};

export default function reducer(state = initialState, action = {}) {  
  switch (action.type) {
    case "DISTRIBUTIONS":
      return {...state, distributions: action.distributions}
    case "DISTRIBUTIONS_ERROR":
      return {...state, errors: action.errors}
    default:
      return state;
  }
}

And we'll allow the root reducer to take in this reducer to shape our application state:

# /web/static/js/reducers/index.js

import { combineReducers }  from 'redux';  
import distributions    from './distributions';

export default combineReducers({  
  distributions
});

Now let's build out some components.

The Container Component

Our App component should contain the component that holds the form to submit a list of ISRCs and the component that displays the list of results from YouTube.

/web/static/js/components/containers/app.js

import React, { Component } from 'react';  
import Nav                  from '../Nav';  
import StatusForm           from '../StatusForm';  
import Distributions        from '../Distributions';  
class App extends Component {  
  render() {
    return (
      <div>
        <Nav/>
        <div className="container">
          <StatusForm />
          <div className="page-header">
            <h3>Results</h3>
          </div>
          <Distributions />
        </div>
      </div>
    );
  }
}

export default App;  

The Form Component

The StatusForm component has two jobs:

  • Display the form for submitting a list of ISRCs
  • Submit that form.

Our component will function as a container component. It will contain a child presentational, or functional, component that simply displays the form.

import React                from 'react';  
import FormElement          from '../formElement';

class StatusForm extends React.Component {  
  handleSubmit(isrcs) {
    // dispatch an action that submits the form
  }

  render() {
    return (
       <FormElement handleSubmit={::this.handleSubmit}/>
    );
  }
}

export default StatusForm  

Our child presentational component will be pretty simple. It will define the form HTML and listen to the onSubmit event. When that event fires, it will collect the ISRCs from the form and invoke the parent component's handleSubmit function with an argument of those ISRCs.

StatusForm's handleSubmit function is responsible for submitting the form. What does it mean to submit this form? What do we want to happen when we submit the form?

We need to take this ISRCs and send them to an endpoint of our Phoenix API. This endoint will use the Deliveries.YouTube.Status module to make the YouTube API request. The endpoint will capture the response from that request and pass it back to our React front-end.

We'll use an action creator function to make the API request to Phoenix and dispatch the result of that request to the reducer, thus updating React's application state.

In order to dispatch an action from our component, we'll use Redux's connect function. This will give us access to the dispatch function that allows us to invoke action creator functions. Then, we'll use dispatch to invoke our getDistributionsStatus action within our handleSubmit function.

import React                from 'react';  
import { connect }          from 'react-redux';  
import Actions              from '../actions/distributions';  
import FormElement          from '../formElement';

class StatusForm extends React.Component {  
  handleSubmit(isrcs) {
    dispatch(Actions.getDistributionsStatus(isrcs));
  }

  render() {
    return (
       <FormElement handleSubmit={::this.handleSubmit}/>
    );
  }
}

export default connect()(StatusForm);  

Let's go ahead and define that getDistributionsStatus action creator function now.

The Action Creator Function

Our action creator function will send a request to an endpoint in our Phoenix app with the given ISRCs. That endpoint will then use our Deliveries.YouTube.Status module from our separate Deliveries child app to query the YouTube API, and pass the response back to React.

// /web/static/js/actions/distributions.js

import { httpGet }  from '../utils';

const Actions = {  
  getDistributionsStatus: (isrcs) => {
    var isrcs = isrcs.split(/[\s,]+/)
    return dispatch => {
      httpGet('/api/v1/status?isrcs=' + isrcs)
      .then((data) => {
        if (data.errors)
          dispatch({
            type: "DISTRIBUTIONS_ERROR",
            errors: data.errors
          })
        else
          dispatch({
            type: "DISTRIBUTIONS",
            distributions: data.distributions
          });
      }).catch(data => {
        dispatch({
          type: "DISTRIBUTIONS_ERROR",
          errors: data
        })
      });
    };
  }
};

export default Actions;  

Our action creator function relies on a utility that we've defined in /web/static/js/utils, which uses the Fetch API to make web requests.

# /web/js/static/utils/index.js

import React        from 'react';  
import fetch        from 'isomorphic-fetch';  
import { polyfill } from 'es6-promise';

export function checkStatus(response) {  
  if (response.status >= 200 && response.status < 300) {
    return response;
  } else {
    var error = new Error(response.statusText);
    error.response = response;
    throw error;
  }
}

export function parseJSON(response) {  
  return response.json();
}

export function httpGet(url) {  
  return fetch(url)
  .then(checkStatus)
  .then(parseJSON);
}

Remember that we've enabled our action creator function to utilize the asynchronous code of Fetch thanks to the Redux Thunk middleware.

Our action creator function will use Fetch to make a request to our Phoenix API. Thanks to our Thunk middleware, our app will wait until this promise is resolved, and then dispatch the our action object with keys of type and either errors or distributions to our distributions reducer. Our reducer will return a new copy of state, causing our component tree to re-render.

The Results Component

Our Distributions component will make use of the mapStateToProps function to pluck the distributions and/or errors out of state. Our Distributions component will also rely on a child component to display the details of a given result item. We won't go into that child component here.

# /web/stati/js/components/Distributions.js

import React                from 'react';  
import { connect }          from 'react-redux';  
import DistroError          from './DistroError';  
import DistributionDetails        from './DistributionDetails'

class Distributions extends React.Component {  
  renderDistros() {
    const {distributions, errors} = this.props.distributions
    if (errors) {
       return (
        <DistroError
          errors={errors}/>
      )
    } else {
      return (
        <DistributionDetails distros={distributions} />
      )
    }
  }

  render() {
    return (
      <div>
       {this.renderDistros()}
      </div>
    );
  }
}

function mapStateToProps(state) {  
  return {distributions: state.distributions}
}

export default connect(mapStateToProps)(Distributions)  

Now we're ready to fill in out missing back-end: the Phoenix API.

The Phoenix API

Our Phoenix API only needs one endpoint: /api/v1/status. This endpoint should respond to a GET request and expect that request to include the isrc parameter.

Defining Routes

First things first, we'll define the route we need.

We'll add the /status route to our Phoenix router, scoped under :api, and mapped to the index function of the StatusController.

/apps/ytsr_status/lib/ystr_status/web/router.ex
...
scope "/api", YtsrStatus.Web do  
    pipe_through :api

    scope "/v1" do
      get "/status", StatusController, :index
    end
  end

Next up, our StatusController.

Defining the Controller

This controller is pretty simple, it should implement an index function that expects a second parameter that matches the pattern: %{"isrcs"=> some_list}, as this is the format in which our React app will make the request.

defmodule YtsrStatus.Web.StatusController do  
  use YtsrStatus.Web, :controller

  def index(conn, %{"isrcs" => isrcs}) do
    # do the things
  end
end  

Our index function should use the Deliveries.YouTube.Status module defined in our separate child application, Deliveries, to make the request to YouTube and send the formatted response back to React.

# /web/controllers/status_controller.ex

defmodule YtsrStatus.Web.StatusController do  
  use YtsrStatus.Web, :controller

  def index(conn, %{"isrcs" => isrcs}) do
    response = String.split(isrcs, ",")
    |> Deliveries.YouTube.Status.status_for
    case response do
      {:ok, %{"items" => items}} ->
        render(conn, "index.json", distributions:  items)
      {:ok, body} ->
        render(conn, "index.json", distributions: [])
      {:error, body} ->
        conn
        |> render("400.json", errors: body["error"])
    end
  end
end  

Here, we use a case statement to pattern match the result of our call to the Deliveries.YouTube.Status module to structure the appropriate response.

The call to render will look for a render function defined in this controller's corresponding view. Let's build it!

Defining the View

defmodule YtsrStatus.Web.StatusView do  
  use YtsrStatus.Web, :view

   def render("index.json", %{distributions: distributions}) do
    %{
      distributions: distributions,
      total: total
    }
  end

  def render("400.json", %{errors: errors}) do
    %{errors: errors}
  end
end  

And that's it!

Conclusion

While our umbrella app is pretty narrowly scoped––dealing only with checking song ISRC status in YouTube––we can start to see the utility of an Elixir umbrella app. It allows us to modularize our code, while still managing all of the responsibilities of a single domain in one place, under one centralized parent.

In our umbrella app, our separate Deliveries application defines a YouTube API client which we can use across future umbrella child apps. The functionality of communicating with the YouTube API is decoupled from the UX of our Phoenix app. Meanwhile, our Phoenix app can utilize our YouTube client via the interface exposed by the Deliveries app. We can easily see how this domain could grow to include communications with other stores and partners, besides YouTube, and house additional user interfaces. The umbrella structure will accommodate this future growth in a sane and centralized manner.

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus