## 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 theApp
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.