Create a React with Redux and React Router application using a Ruby on Rails API.
By Steve Carey - Last updated 7/3/2019
This Tutorial covers:Finished code: Github
In this tutorial we will create a React with Redux and React Router application that interacts with a database to do Create-Read-Update-Delete (CRUD) transactions through an API. That sounds like a lot. And it is. But we'll try to make it as straightforward as possible. We'll use a Ruby on Rails API-only application for our backend, although you can build your own with another framework if you prefer.
This tutorial assumes you are familiar with React, JavaScript ES6 syntax, and the concepts involved with API calls/asynchronous HTTP requests. It assumes only limited knowledge of Redux. You may find the World's Simplest Redux with APIs Example to be a good introduction.
For a CRUD application we need to build an API on the server to interact with the database. This tutorial's main focus is on the React and Redux front end. So you can use any framework you like for building the back end. You just need an endpoint at localhost:3001/articles to receive GET requests for all articles and POST requests to create new ones. And an endpoint at localhost:3001/articles/:id to receive GET, PATCH and DELETE requests for specific articles. And a database table called "articles" with fields for id, title, and content.
If you have a Ruby on Rails environment set up on your computer, we have a whole separate Rails API Tutorial. If you are already familiar with Rails, we'll quickly create an API that meets our requirements right now. Here are the steps starting from your Terminal:
mkdir articles-api; cd articles-api
rails new . --api
rails generate scaffold Article title:string content:text
rails db:migrate
# app/controllers/articles_controller.rb ... def index @articles = Article.all.order(created_at: :desc) render json: @articles end
# db/seed.rb Article.create(title: "Learn React", content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") Article.create(title: "Learn APIs", content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") Article.create(title: "Learn Redux", content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
rails db:seed
# Gemfile gem 'rack-cors', require: 'rack/cors'
bundle install
# config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins '*' resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end
Add an "api" namespace to the routes. This will avoid potential route collisions between your back-end api and your front-end react routes.
# config/routes.rb scope '/api' do resources :articles end
rails server -p 3001
Your API is ready to go.
Use Create React App to set up a React app.
create-react-app redux-crud-app
cd redux-crud-app
Install your packages:
npm install --save redux react-redux redux-thunk redux-logger react-router-dom axios
yarn add redux react-redux redux-thunk redux-logger react-router-dom axios
Run the server to make sure it's working:
npm start
or yarn start
You should see the default Welcome to React app in your browser.
Use the below Unix commands to create directories and remove, move, and create files.
rm src/logo.svg rm src/App.test.js rm src/registerServiceWorker.js touch src/history.js mkdir src/stylesheets mv src/index.css src/stylesheets/index.css mv src/App.css src/stylesheets/App.css mkdir src/components mv src/App.js src/components/App.jsx touch src/components/ArticleList.jsx touch src/components/ArticleInfo.jsx touch src/components/ArticleAdd.jsx touch src/components/ArticleEdit.jsx mkdir src/actions touch src/actions/index.js mkdir src/reducers touch src/reducers/index.js touch src/reducers/articlesReducer.js touch src/reducers/articleReducer.js
We'll cover these as we build the app but just as a high level overview, Redux uses actions and reducers. Our app is just big enough to warrant separate folders for each. And each has an index.js file which is automatically called by Redux when you reference the folder.
The components folder holds our React components. Redux recommends splitting all your React components into Presentational Components, which hold the JSX, and Container components, which interact with Redux. We're not going to do that because our component files will be pretty slim as it is, and it's easier to understand the flow of the code if we don't. In a real-world app with lots of stuff in each component it would make sense to split them that way.
For convenience we'll use the Bootstrap CDN for our styling. Import it at the top of the index.css file.
// src/stylesheets/index.css @import url('https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css');
Our app is broken now but we'll get it working again shortly.
First we'll set up the main index.js file for our React with Redux app.
// src/index.js import React from 'react'; import ReactDOM from 'react-dom'; import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import logger from 'redux-logger' import { Provider } from 'react-redux'; import './stylesheets/index.css'; import App from './components/App'; import rootReducer from './reducers'; const store = createStore(rootReducer, applyMiddleware(thunk, logger)); #1 ReactDOM.render( <Provider store={store}> #2 <App /> </Provider>, document.getElementById('root') );
The first argument in createStore() is the reducer. Our app will have two reducer functions, one for an array of articles, and one for a specific article. When you have more than one reducer you need to combine them into a single reducer object using Redux's combineReducers method. We can set that up now in the reducers index file even though we haven't defined the reducers yet.
// src/reducers/index.js
import { combineReducers } from 'redux';
import articles from './articlesReducer';
import article from './articleReducer';
export default combineReducers({
articles: articles,
article: article,
});
React Router is the most popular Routing package for React. It can work independently of Redux. Quoting from Redux docs:
"Redux will be the source of truth for your data and React Router will be the source of truth for your URL. In most of the cases, it is fine to have them separate unless you need to time travel and rewind actions that trigger a URL change."
Our navigation will be contained in the App component. We will set up a nav bar with two menu items, articles and new article, and add all our routes up front.
// src/components/App.jsx import React, { Component } from 'react'; import ArticleAdd from './ArticleAdd'; import ArticleList from './ArticleList'; import ArticleInfo from './ArticleInfo'; import ArticleEdit from './ArticleEdit'; import {Router, Route, NavLink, Switch} from 'react-router-dom' import history from '../history'; class App extends Component { render() { return ( <Router history={history}> <div className="container"> <Navigation /> <Main /> </div> </Router> ); } } const Navigation = () => ( <nav className="navbar navbar-expand-lg navbar-dark bg-dark"> <ul className="navbar-nav mr-auto"> <li className="nav-item"><NavLink exact className="nav-link" activeClassName="active" to="/articles">Articles</NavLink></li> <li className="nav-item"><NavLink exact className="nav-link" activeClassName="active" to="/articles/new">Add Article</NavLink></li> </ul> </nav> ); const Main = () => ( <Switch> <Route exact path="/" component={ArticleList} /> <Route exact path="/articles" component={ArticleList} /> <Route exact path="/articles/new" component={ArticleAdd} /> <Route exact path="/articles/:id" component={ArticleInfo} /> <Route exact path="/articles/:id/edit" component={ArticleEdit} /> </Switch> ); export default App;
We won't cover how React Router works in this tutorial. The docs are at reacttraining.com/react-router/core/guides.
There is one thing different than a React without Redux app. We are importing from a file called "history" and adding it as an attribute to the Router element: <Router history={history}>
. This is because we will be using functions outside of our components to interact with Redux. Per React-Router's docs, this requires accessing React's build-in history module. Populate the history file with these two lines:
// src/history.js import { createBrowserHistory } from 'history'; export default createBrowserHistory();
To test if our initial Redux app and router are working let's add placeholder components to our component files.
// src/components/ArticleList.jsx import React from 'react'; export default function Placeholder() { return (<h1>Articles Page</h1>); }
// src/components/ArticleAdd.jsx import React from 'react'; export default function Placeholder() { return (<h1>Add Article</h1>); }
// src/components/ArticleInfo.jsx import React from 'react'; export default function Placeholder() { return (<h1>Article Info</h1>); }
// src/components/ArticleEdit.jsx import React from 'react'; export default function Placeholder() { return (<h1>Edit Article</h1>); }
Restart the server: npm start
or yarn start
Now when you go to localhost:3000 you should see a navbar with two working routes. Our routes are working! Now it's time to create our first API call.
Let's make our first API call and update our User Interface with our first Action. From an HTTP request perspective, this is a GET request. From a database CRUD perspective this is a Read command.
Let's initially populate our app with a list of articles pulled from the database and served by the API. Since we set up a Redux store object to be our single source of truth, and since the ArticleList page is our root route, let's add two lines to our initial src/index.js file:
// src/index.js import React from 'react'; import ReactDOM from 'react-dom'; import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import logger from 'redux-logger' import { Provider } from 'react-redux'; import './stylesheets/index.css'; import App from './components/App'; import rootReducer from './reducers'; import { getArticles } from './actions'; #1 const store = createStore(rootReducer, applyMiddleware(thunk, logger)); store.dispatch(getArticles()); #2 ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') );
The getArticles method includes our first Action. Since we will have several actions we will define them all in the actions folder.
// src/actions/index.js import axios from 'axios'; #1 export const RECEIVE_ARTICLES = 'GET_ARTICLES'; #2 const apiUrl = 'http://localhost:3001/api/articles'; #3 export const getArticles = () => { #4 return (dispatch) => { return axios.get(`${apiUrl}.json`) #5 .then(response => { dispatch({type: RECEIVE_ARTICLES, articles: response.data}) #6 }) .catch(error => { throw(error); }); }; };
Let's break this down.
Rather than defining our Action object directly in the dispatch method, we could create a separate Action Creation Function and call it from dispatch() like the below. We aren't going to do that in this tutorial, but be aware that's a common way to do it. And of course you can break all the action functions out this way if you like.
const receiveArticles = (data) => ({ type: RECEIVE_ARTICLES, articles: data, }); export const getArticles = () => { return (dispatch) => { return axios.get(`${apiUrl}.json`) .then(response => { dispatch(receiveArticles(response.data)) }) .catch(error => { throw(error); }); }; };
And if we wanted to add a "Loading..." spinner between when the API request is made and when the response is received, we could add another action called REQUEST_ARTICLES, and change the User Interface to show a spinner. But we'll keep it simple and skip that.
Now that we've sent our retrieved articles to the reducer with our action, let's define the reducer.
// src/reducers/articlesReducer.js import { RECEIVE_ARTICLES } from '../actions'; #1 const initialState = { articles: [] } export default function articlesReducer(state = initialState, action) { #2 switch (action.type) { case RECEIVE_ARTICLES: #3 return action.articles; default: #4 return state; } }
Now our store contains an an articles array with the articles from the API. Time to use that to change the User Interface. Replace our placeholder component with this:
// src/components/ArticleList.jsx import React, { Component } from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; class ArticleList extends Component { render() { #1 if(this.props.articles.length) { #2 return ( <div> <h4>Articles</h4> {this.props.articles.map(article => { #3 return ( <div key={ article.id }> #4 <hr/> <h4><Link to={`/articles/${article.id}`}>{article.id}: {article.title}</Link></h4> #5 <p>{article.content}</p> </div> ); })} </div> ) } else { return (<div>No Articles</div>) } } } const mapStateToProps = (state) => ({ articles: state.articles }); #6 export default connect(mapStateToProps)(ArticleList); #7
this.props
to access the articles. How did the articles array get into the component's props? With React-Redux's mapStateToProps method, that's how. This method you accesses the store's current state and maps it to your props according to your specifications. Here we are mapping the articles props to the articles array from the store.Now if you entered all the above code, and your API server is running on port 3001, you should be able to go to your browser and see your list of articles. Our first action is a success!
We have good momentum after our first successful Redux Action. Time for our second. This time our component will display a form to submit a new article. We will send an HTTP POST request to the appropriate API endpoint, which should Create a new record in the database. Then we'll update the Redux store to add the new article to the articles array and redirect our route back to the ArticleList page.
This time everything begins in the component. Replace the placeholder component with this new shiny one.
// src/components/ArticleAdd.jsx import React from 'react'; import { connect } from 'react-redux'; import { addArticle } from '../actions'; #1 class ArticleAdd extends React.Component { state = { title: '', content: '' }; handleChange = (event) => { this.setState({ [event.target.name]: event.target.value }); }; handleSubmit = (event) => { event.preventDefault(); this.props.addArticle(this.state); #4 }; render() { return ( <div> <h4>Add Article</h4> <form onSubmit={ this.handleSubmit }> <div className="form-group"> <input type="text" name="title" required value={this.state.title} onChange={this.handleChange} className="form-control" placeholder="Title" /> </div> <div className="form-group"> <textarea name="content" rows="5" required value={this.state.content} onChange={this.handleChange} className="form-control" placeholder="Content" /> </div> <button type="submit" className="btn btn-dark">Create</button> </form> </div> ); } } const mapDispatchToProps = { addArticle }; #2 export default connect(null, mapDispatchToProps)(ArticleAdd); #3
Most of this is similar to plain React. Set initial state to empty values. Create a form with fields that use the onChange event and a handle change handler function to update the fields as you type. And have an onSubmit event with a handle submit handler function. Then React-Redux takes over.
Time to define our addArticle function. We'll add it to the actions folder.
// src/actions/index.js import axios from 'axios'; import history from '../history'; export const RECEIVE_ARTICLES = 'GET_ARTICLES'; export const ADD_ARTICLE = 'ADD_ARTICLE'; const apiUrl = 'http://localhost:3001/api/articles'; ... export const addArticle = ({ title, content }) => { #1 return (dispatch) => { return axios.post(`${apiUrl}.json`, {title, content}) #2 .then(response => { let data = response.data; dispatch({type: ADD_ARTICLE, payload: {id: data.id, title: data.title, content: data.content}}) #3 }) .then(() => { history.push("/articles") #4 }) .catch(error => { throw(error) }); }; };
This is similar to the getArticles function. Only this time it is a post request so we are sending data.
Last step is to add the action to the articlesReducer.
// src/reducers/articlesReducer.js import { RECEIVE_ARTICLES, ADD_ARTICLE } from '../actions'; #1 const initialState = { articles: [] } export default function articlesReducer(state = initialState, action) { switch (action.type) { case RECEIVE_ARTICLES: return action.articles; case ADD_ARTICLE: #2 return [action.payload, ...state]; default: return state; } }
Now try it out. In the browser you should be able to click on the Add Article link, fill out and submit the form, and be redirected to the articles page with the new article on top. Second action complete!
Our articles page lists all the articles from the API, and each article title has a link to an ArticleInfo page. Clicking it will result in another HTTP GET request to the API URL appended with the article id (e.g., localhost:3001/articles/3). We will also have a button to send an HTTP DELETE request. From a database CRUD prespective we will be using Read and Delete commands.
Start with the component:
// src/components/ArticleInfo.jsx import React, { Component } from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { getArticle, deleteArticle } from '../actions'; class ArticleInfo extends Component { componentDidMount() { #4 this.props.getArticle(this.props.match.params.id); } render() { const article = this.props.article; return ( <div> <h2>{article.id}: {article.title}</h2> <p>{article.content}</p> <div className="btn-group"> <Link to={{ pathname: `/articles/${article.id}/edit`, state: { article: article } }} className='btn btn-info'> #5 Edit </Link> <button className="btn btn-danger" type="button" onClick={() => this.props.deleteArticle(article.id)}> #6 Delete </button> <Link to="/articles" className="btn btn-secondary">Close</Link> #7 </div> <hr/> </div> ) } } const mapStateToProps = (state) => ({ article: state.article }); #1 const mapDispatchToProps = { getArticle, deleteArticle }; #2 export default connect(mapStateToProps, mapDispatchToProps)(ArticleInfo); #3
We'll go in the order that the statements are executed:
Add the getArticle and deleteArticle functions to the actions folder:
// src/actions/index.js import axios from 'axios'; import history from '../history'; export const RECEIVE_ARTICLES = 'GET_ARTICLES'; export const ADD_ARTICLE = 'ADD_ARTICLE'; export const RECEIVE_ARTICLE = 'RECEIVE_ARTICLE'; export const REMOVE_ARTICLE = 'REMOVE_ARTICLE'; const apiUrl = 'http://localhost:3001/api/articles'; ... export const getArticle = (id) => { return (dispatch) => { return axios.get(`${apiUrl}/${id}.json`) .then(response => { dispatch({type: RECEIVE_ARTICLE, article: response.data}); }) .catch(error => { throw(error); }); }; }; export const deleteArticle = (id) => { return (dispatch) => { return axios.delete(`${apiUrl}/${id}.json`) .then(response => { dispatch({type: REMOVE_ARTICLE, payload: {id}}) }) .then(() => { history.push("/articles") }) .catch(error => { throw(error); }); }; };
Populate the articleReducer file. This is not the same file or reducer as articlesReducer. ArticleReducer manages state and actions for a single article object, while the articlesReducer manages state and actions for an array of article objects.
// src/reducers/articleReducer.js import { RECEIVE_ARTICLE } from '../actions'; export default function articleReducer(state = {}, action) { #1 switch (action.type) { case RECEIVE_ARTICLE: #2 return action.article; default: return state; } };
The deleteArticle function deletes the article from the database and redirects back to the Articles page. But we want the deleted article to be removed from the articles list. We could refresh the api query but that wouldn't be very efficient. Instead we use the articlesReducer to remove it from the articles array in the store.
// src/reducers/articlesReducer.js import { RECEIVE_ARTICLES, ADD_ARTICLE, REMOVE_ARTICLE } from '../actions'; const initialState = { articles: [] } export default function articlesReducer(state = initialState, action) { switch (action.type) { case RECEIVE_ARTICLES: return action.articles; case ADD_ARTICLE: return [action.payload, ...state]; case REMOVE_ARTICLE: return state.filter(article => article.id !== action.payload.id); default: return state; } }
Now test it out. Create an article, click on its info page, then delete it. That's two more actions we've successfully programmed. On to the final piece of CRUD. Update.
Now let's add an edit form that makes an HTTP PATCH request to the server and Updates an existing article in the database.
// src/components/ArticleEdit.jsx
import React from 'react';
import { connect } from 'react-redux';
import { updateArticle } from '../actions';
class ArticleEdit extends React.Component {
handleChange = (event) => {
this.setState({ [event.target.name]: event.target.value });
};
handleSubmit = (event) => { #1
event.preventDefault();
const id = this.props.article.id;
const title = this.state.title ? this.state.title : this.props.article.title;
const content = this.state.content ? this.state.content : this.props.article.content;
const article = {id: id, title: title, content: content}
this.props.updateArticle(article);
};
handleCancel = () => {
this.props.history.push(`/articles/${this.props.article.id}`);
}
render() {
return (
<div>
<h1>Edit {this.props.article.title}</h1>
<form onSubmit={this.handleSubmit}>
<div className="form-group">
<label>Title</label>
<input type="text" name="title" defaultValue={this.props.article.title} onChange={this.handleChange} className="form-control" />
</div>
<div className="form-group">
<label>Content</label>
<textarea name="content" rows="5" defaultValue={this.props.article.content} onChange={this.handleChange} className="form-control" />
</div>
<div className="btn-group">
<button type="submit" className="btn btn-dark">Update</button>
<button type="button" onClick={this.handleCancel} className="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
);
}
}
const mapStateToProps = (state) => ({ article: state.article });
const mapDispatchToProps = { updateArticle };
export default connect(mapStateToProps, mapDispatchToProps)(ArticleEdit);
We won't go line by line this time since most of the concepts were covered in ArticleInfo and ArticleAdd.
The original state is still the article object from ArticleInfo. We are not doing a database refresh pull since we already have the info in the store. In a production app you would add some code to do an API call if the user refreshes the edit page which will clear out the store, but we'll keep it simple and not do that.
Now let's finish the Actions page. This is the final code.
// src/actions/index.js
import axios from 'axios';
import history from '../history';
export const RECEIVE_ARTICLES = 'GET_ARTICLES';
export const ADD_ARTICLE = 'ADD_ARTICLE';
export const RECEIVE_ARTICLE = 'RECEIVE_ARTICLE';
export const REMOVE_ARTICLE = 'REMOVE_ARTICLE';
export const UPDATE_ARTICLE = 'UPDATE_ARTICLE';
export const REPLACE_ARTICLE = 'REPLACE_ARTICLE';
const apiUrl = 'http://localhost:3001/api/articles';
export const getArticles = () => {
return (dispatch) => {
return axios.get(`${apiUrl}.json`)
.then(response => {
dispatch({type: RECEIVE_ARTICLES, articles: response.data})
})
.catch(error => { throw(error); });
};
};
export const addArticle = ({ title, content }) => {
return (dispatch) => {
return axios.post(`${apiUrl}.json`, {title, content})
.then(response => {
let data = response.data;
dispatch({type: ADD_ARTICLE, payload: {id: data.id, title: data.title, content: data.content}})
})
.then(() => {
history.push("/articles")
})
.catch(error => { throw(error) });
};
};
export const getArticle = (id) => {
return (dispatch) => {
return axios.get(`${apiUrl}/${id}.json`)
.then(response => {
dispatch({type: RECEIVE_ARTICLE, article: response.data});
})
.catch(error => {
throw(error);
});
};
};
export const deleteArticle = (id) => {
return (dispatch) => {
return axios.delete(`${apiUrl}/${id}.json`)
.then(response => {
dispatch({type: REMOVE_ARTICLE, payload: {id}})
})
.then(() => {
history.push("/articles")
})
.catch(error => {
throw(error);
});
};
};
export const updateArticle = (article) => { #1
const articleId = article.id;
return (dispatch) => {
return axios.patch(`${apiUrl}/${article.id}.json`, {title: article.title, content: article.content})
.then(response => {
const data = response.data;
dispatch({type: UPDATE_ARTICLE, payload: {id: data.id, title: data.title, content: data.content}})
dispatch({type: REPLACE_ARTICLE, payload: {id: data.id, title: data.title, content: data.content}})
})
.then(() => {
history.push(`/articles/${articleId}`)
})
.catch(error => { throw(error) });
};
};
The dispatch with action type UPDATE_ARTICLE goes to the articleReducer to change the article object in the store.
// src/actions/articleReducer.js import { RECEIVE_ARTICLE, UPDATE_ARTICLE } from '../actions'; export default function articleReducer(state = {}, action) { switch (action.type) { case RECEIVE_ARTICLE: return action.article; case UPDATE_ARTICLE: return { id: action.id, title: action.payload.title, content: action.payload.content, } default: return state; } };
The dispatch with action type REPLACE_ARTICLE goes to the articlesReducer to replace the appropriate article object in the articles array.
// src/actions/articlesReducer.js import { RECEIVE_ARTICLES, ADD_ARTICLE, REMOVE_ARTICLE, REPLACE_ARTICLE } from '../actions'; const initialState = { articles: [] } export default function articlesReducer(state = initialState, action) { switch (action.type) { case RECEIVE_ARTICLES: return action.articles; case ADD_ARTICLE: return [action.payload, ...state]; case REMOVE_ARTICLE: return state.filter(article => article.id !== action.payload.id); case REPLACE_ARTICLE: return state.map((article) => { if (article.id === action.payload.id) { return { ...article, title: action.payload.title, content: action.payload.content, } } else return article; }) default: return state; } }
Make sure all the files are saved, then try it out in the browser. Add an article, click to go to its info page, update it, then delete it. Congratulations you now have a fully functioning React with Redux and React Router app that can perform all four CRUD operations on the database through the API. Whew.