Node.js in 2018: Full stack tutorial with Koa, React, Redux, Sagas and MongoDB
Introduction
With so much choice in 2018 for web development, it can get quickly get really confusing trying to understand what all these terms mean and how each part works together to build a web app. It may seem like the days of simply using HTML, CSS and JS to build a web app are over, but once you understand the ideas here you’ll see it’s only become more powerful.
The aim of this tutorial is to practically show you my favourite tech stack to use day to day as a Software Developer at [jtribe](https://jtribe.com.au/). This will be a step-by-step guide on how to set up and build a basic application which goes full stack from the backend to the frontend, database and all! Even going as far deploying your application into the real world 🙂
DISCLAIMER: This is definitely overkill for a simple Todo app, but all of the concepts learnt here can be scaled up to very large applications and it doesn’t get much more complex than what you see here.
If you want to skip to the end, all the code is available at:
https://github.com/rhysdavies1994/koa-react-todo-tutorial
Tech stack
Backend
Node.js using Koa for our server
MongoDB — NoSQL Database for persisting our data
Mongoose — Database layer for MongoDB
Now — Deploying our app
Frontend
React — View layer
Redux — State management
Redux Sagas — Side effects and Async
Bulma — Lightweight CSS framework based on flex
What we’ll be building
A basic Todo app with CRUD features.
CRUD operations:
Create todo and store it in a list of data
Read todos from a list of data
Update todo to mark it as complete
Delete todo
Let’s get started
Step 1: Ensure you’ve got Node.js and NPM setup
Easiest way to install node
Go to https://nodejs.org/en/ and click download current version, run the installer.
Advanced setup
To have more control over your node versions for different projects,
I’d recommend setting up something called Node Version Manager (NVM).
- Follow installation instructions at https://github.com/creationix/nvm
nvm install node
nvm use node
After either of these setup options you should have access to two new commands:
- node
: Node runtime to run JavaScript on your computer/server.
- npm
: Node Package Manager to install and manage JavaScript packages
Step 2: Set up Koa backend
Koa is super simple to set up, but to save some time we are going to use a basic boilerplate. There is a nice one called [Koalerplate](https://github.com/dbalas/koalerplate), it comes with some great configuration that every project can make use of, I also just like the name.
We’re going to create an overarching project folder, then have a backend and frontend directory to keep them separate:
mkdir my-project-name
cd my-project-name
Then while inside your project directory,
follow the instructions at: https://github.com/dbalas/koalerplate, or TLDR;
git clone https://github.com/dbalas/koalerplate.git backend
cd backend
mv .env.sample .env
npm install
npm run dev
Now you should be able to open up http://localhost:3000/v1/users and be greeted with an empty object {}
. The backend is alive!
Structure of our backend
- package.json defines your project from nodes perspective, change project details and dependencies here.
- index.js is your entry point to the app.
- server.js is where koa has been defined and configured
- routes/index.js is where your routes are defined
- routes/<route-name>.js is where you’ll set up a route
- controllers/<route-name>.js is where you’ll define the logic for your routes
- models is where you’ll configure your database models
- normally we’d have a views folder for MVC pattern, but React will be take care of our view layer
Next we’ll continue building out our Backend, rather than switching into setting up the Frontend.
Step 3: Communicating to our Database
Create a MongoDB instance
We’ll be using mLab in this tutorial so we can get a free database with up to 500mb storage.
- Head to https://mlab.com and sign up for an account.
- Once you are logged in, create a new MongoDB deployment
3. After you’ve created your Mongo database, create a database user to have access to it
Set up Mongoose
We’ll be using the Mongoose library to easily communicate with our MongoDB instance.
- Add the mongoose package to our backend:
npm install mongoose — save
- Connect mongoose to our server:
/* server.js */
// … Other required modules
const mongoose = require(‘mongoose’);
// … Koa code
mongoose.connect(‘mongodb://<dbuser>:<dbpassword>@eg12345.mlab.com:12345/my_database’)
module.exports = app
3. Create our Todo Model:
/* models/todo.js */
const mongoose = require(‘mongoose’)
// Declare Schema
const TodoSchema = new mongoose.Schema(
{
description: { type: String },
done: { type: Boolean },
},
{ timestamps: true }
);
// Declare Model to mongoose with Schema
const Todo = mongoose.model(‘Todo’, TodoSchema)
// Export Model to be used in Node
module.exports = mongoose.model(‘Todo’)
NOTE: adding option {timestamps: true}
automatically sets createdAt and updatedAt values on the model in your mongo database.
Build the Todos Route
- Create
routes/todos.js
, we’ll get to the controller methods in a second
const Router = require(‘koa-router’)
const router = new Router()
const Ctrl = require(‘../controllers/todos’)
router.get(‘/’, Ctrl.findAll)
router.post(‘/’, Ctrl.create)
router.post(‘/:id’, Ctrl.update)
router.delete(‘/:id’, Ctrl.destroy)
module.exports = router.routes()
2. Configure todos route on index router
/* routes/index.js */
module.exports = (router) => {
router.prefix(‘/v1’)
router.use(‘/todos’, require(‘./todos’))
}
Implement CRUD operations in Todos controller
Create controllers/todos.js
const Todo = require(‘../models/todo’)
async function findAll (ctx) {
// Fetch all Todo’s from the database and return as payload
const todos = await Todo.find({})
ctx.body = todos
}
async function create (ctx) {
// Create New Todo from payload sent and save to database
const newTodo = new Todo(ctx.request.body)
const savedTodo = await newTodo.save()
ctx.body = savedTodo
}
async function destroy (ctx) {
// Get id from url parameters and find Todo in database
const id = ctx.params.id
const todo = await Todo.findById(id)
// Delete todo from database and return deleted object as reference
const deletedTodo = await todo.remove()
ctx.body = deletedTodo
}
async function update (ctx) {
// Find Todo based on id, then toggle done on/off
const id = ctx.params.id
const todo = await Todo.findById(id)
todo.done = !todo.done
// Update todo in database
const updatedTodo = await todo.save()
ctx.body = updatedTodo
}
module.exports = {
findAll,
create,
destroy,
update
}
Now we have a functional backend with CRUD operations talking to our database. Examples:
GET /v1/todos -> Returns all todos in database
POST /v1/todos -> Create new todo
POST /v1/todos/1 -> Update todo with id of 1
DELETE /v1/todos/1 -> Delete todo with id of 1
Step 4: Set up React Frontend
Generate project using Create React App
We are going to use create-react-app to instantiate our React frontend. CRA comes with great build configuration using webpack with hot reloading and a bunch of other goodness straight out of the box. You don’t even need to see this config unless you want to eject.
More info at: https://github.com/facebook/create-react-app
Inside of our main project directory (not backend):
npx create-react-app frontend — use-npm
cd frontend
npm start
Then you should see our basic frontend load up inside your browser on http://localhost:3000
Get the frontend to talk to the backend in development
- Change backend to be on a different port. Right now our frontend is on port 3000 and so is our backend, let’s change our backend to port 4000 so they don’t conflict:
/* backend/index.js */
// …
const port = process.env.PORT || 4000
3. Add Koa static to backend to serve up compiled React app:
/* Inside backend folder */
npm install koa-static — save
/* backend/server.js */
// … below app.use(router.routes()) so api has higher priority
app.use(require(‘koa-static’)(‘./build’))
4. Add proxy in frontend package.json and improve build script:
/* frontend/package.json */
// … scripts : { …
“build”: “rm -rf ./build && react-scripts build && rm -rf ../backend/build && mv ./build ../backend/build”
},
“proxy”: “http://localhost:4000”
}
```
5. Optional: Create overarching project to run backend and frontend concurrently:
/* from main folder directory */
npm init // fill out details of your project
npm install concurrently — save-dev
// Add start, backend and frontend scripts to package.json
/* package.json */
{
“name”: “todo-tutorial”,
“version”: “0.1.0”,
“private”: true,
“dependencies”: {},
“scripts”: {
“start”: “concurrently — kill-others \”npm run backend\” \”npm run frontend\””,
“backend”: “cd backend && npm run dev”,
“frontend”: “cd frontend && npm start”,
“build”: “cd frontend && npm run build”
},
“devDependencies”: {
“concurrently”: “³.5.1”
}
}
```
Now during development, we can just run `npm start` from our main project directory to start up both the backend and frontend development servers. When we want to deploy to production we can use `npm run build` to compile the frontend and be served by our backend with its API.
Step 5: Building the UI for our App
We’re only going to have one page for this Todo app, so to get started let’s clear out the template that create-react-app gives us and put in our own Todos component.
/* Inside of frontend directory */
cd src
touch Todos.js
/* frontend/src/App.js */
import React, { Component } from ‘react’
import ‘./App.css’
import Todos from ‘./Todos’
class App extends Component {
render() {
return (
<div className=”App”>
<Todos />
</div>
)
}
}
export default App
/* frontend/src/Todos.js */
import React, { Component } from ‘react’
class Todos extends Component {
render () {
return (
<div>Hello</div>
)
}
}
export default Todos
Next;
- We’ll add Bulma to help style our Todos component
- Get our component to load the Todo’s from our backend
- and render them in a nice looking layout.
/* Inside frontend directory*/
npm install bulma — save
/* frontend/src/Todos.js */
import React, { Component } from ‘react’
import ‘bulma/css/bulma.css’
const Todo = ({ todo, id }) => (
<div className=”box todo-item level is-mobile”>
<div className=”level-left”>
<label className={`level-item todo-description ${todo.done && ‘completed’}`}>
<input className=”checkbox” type=”checkbox”/>
<span>{todo.description}</span>
</label>
</div>
<div className=”level-right”>
<a className=”delete level-item” onClick={() => {}}>Delete</a>
</div>
</div>
)
class Todos extends Component {
state = {
newTodo: ‘’,
todos: [],
error: ‘’,
isLoading: false
}
componentDidMount() {
this.fetchTodos()
}
fetchTodos () {
this.setState({ isLoading: true })
// HTTP GET Request to our backend api and load into state
fetch(‘v1/todos’)
.then((res) => res.json())
.then(todos => this.setState({ isLoading: false, todos }))
.catch((error) => this.setState({ error: error.message }))
}
addTodo (event) {
event.preventDefault() // Prevent form from reloading page
const { newTodo, todos } = this.state
if(newTodo) {
this.setState({
newTodo: ‘’,
todos: todos.concat({ description: newTodo, done: false })
})
}
}
render() {
let { todos, newTodo, isLoading, error } = this.state
const total = todos.length
const complete = todos.filter((todo) => todo.done).length
const incomplete = todos.filter((todo) => !todo.done).length
return (
<section className=”section full-column”>
<h1 className=”title white”>Todos</h1>
<div className=”error”>{error}</div>
<form className=”form” onSubmit={this.addTodo.bind(this)}>
<div className=”field has-addons” style={{ justifyContent: ‘center’ }}>
<div className=”control”>
<input className=”input”
value={newTodo}
placeholder=”New todo”
onChange={(e) => this.setState({ newTodo: e.target.value })}/>
</div>
<div className=”control”>
<button
className={`button is-success ${isLoading && “is-loading”}`}
disabled={isLoading}>Add</button>
</div>
</div>
</form>
<div className=”container todo-list”>
{todos.map((todo) => <Todo key={todo._id} id={todo._id} todo={todo}/> )}
<div className=”white”>
Total: {total} , Complete: {complete} , Incomplete: {incomplete}
</div>
</div>
</section>
);
}
}
export default Todos
/* frontend/src/App.css */
html { background-color: #222222; }
body { background: cornflowerblue; }
.todo-item { text-align: left; }
.error { color: crimson; }
.white { color: white !important; }
.checkbox { margin-right: 10px; }
.completed { text-decoration-line: line-through; }
.todo-description { cursor: pointer; }
.todo-list { max-width: 400px !important; }
.form { margin-bottom: 50px;}
.App {
text-align: center;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.full-column {
display: flex;
flex: 1;
flex-direction: column;
}
```
Step 6: Moving State Management to Redux
Install required packages in frontend: npm install redux react-redux — save
Setup Redux in React
/* frontend/src/index.js */
// …
import { Provider } from ‘react-redux’
import { createStore, applyMiddleware } from ‘redux’
import rootReducer, { DEFAULT_STATE } from ‘./reducers’
const store = createStore(rootReducer, DEFAULT_STATE)
ReactDOM.render((
<Provider store={store}>
<App />
</Provider>
), document.getElementById(‘root’))
Create root reducer
/* frontend/src/reducers/index.js */
import { combineReducers } from ‘redux’
import todos, { TODOS_DEFAULT_STATE } from ‘./todos’
const todoApp = combineReducers({
todos
})
export const DEFAULT_STATE = {
todos: TODOS_DEFAULT_STATE
}
export default todoApp
Create Redux Action Types and Creators for Todos
/* frontend/src/actions/todos.js */
// action types
export const ADD_TODO = ‘ADD_TODO’
export const ADD_TODO_SUCCESS = ‘ADD_TODO_SUCCESS’
export const TODOS_FAILURE = ‘TODOS_FAILURE’
export const TOGGLE_TODO = ‘TOGGLE_TODO’
export const DELETE_TODO = ‘DELETE_TODO’
export const LOADED_TODOS = ‘LOADED_TODOS’
export const FETCH_TODOS = ‘FETCH_TODOS’
// action creators
export function addTodo(todo) {
return { type: ADD_TODO, todo }
}
export function addTodoSuccess(todo) {
return { type: ADD_TODO_SUCCESS, todo }
}
export function todosFailure(error) {
return { type: TODOS_FAILURE, error }
}
export function toggleTodo(id) {
return { type: TOGGLE_TODO, id }
}
export function deleteTodo(id) {
return { type: DELETE_TODO, id }
}
export function loadedTodos(todos) {
return { type: LOADED_TODOS, todos }
}
export function fetchTodos() {
return { type: FETCH_TODOS }
}
Create Todos Reducer
/* frontend/src/reducers/todos.js */
import {
ADD_TODO,
ADD_TODO_SUCCESS,
TODOS_FAILURE,
TOGGLE_TODO,
DELETE_TODO,
LOADED_TODOS,
FETCH_TODOS
} from ‘../actions/todos’
export const TODOS_DEFAULT_STATE = {
loading: false,
saving: false,
error: ‘’,
items: []
}
export default function todos (state = TODOS_DEFAULT_STATE, action) {
switch (action.type) {
case LOADED_TODOS:
return {…state, items: action.todos, loading: false}
case FETCH_TODOS: {
return {…state, loading: true}
}
case ADD_TODO:
return {…state, saving: true}
case ADD_TODO_SUCCESS:
return {
…state,
items: state.items.concat(action.todo),
saving: false
}
case TODOS_FAILURE:
return {…state, loading: false, saving: false, error: action.error}
case TOGGLE_TODO:
return {
…state,
items: state.items.map((todo) =>
todo._id === action.id ? {…todo, done: !todo.done} : todo
)
}
case DELETE_TODO:
return {
…state,
items: state.items.reduce((items, todo) =>
todo._id !== action.id ? items.concat(todo) : items, []
)
}
default:
return state
}
}
Step 7: Handling Async with Redux Sagas
Install required packages in frontend: npm install redux-saga — save
Setup Redux Sagas in React
/* frontend/src/index.js */
// …
import { Provider } from ‘react-redux’
import { createStore, applyMiddleware, compose } from ‘redux’
import createSagaMiddleware from ‘redux-saga’
import rootReducer, { DEFAULT_STATE } from ‘./reducers’
import rootSaga from ‘./sagas’
const sagaMiddleware = createSagaMiddleware()
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
DEFAULT_STATE,
composeEnhancers(applyMiddleware(sagaMiddleware))
);
sagaMiddleware.run(rootSaga)
//…
Implement Root Saga
import { call, put, takeLatest, takeEvery } from ‘redux-saga/effects’
import { ADD_TODO, DELETE_TODO, TOGGLE_TODO, loadedTodos, addTodoSuccess, todosFailure } from ‘../actions/todos’
function* getAllTodos () {
try {
const res = yield call(fetch, ‘v1/todos’)
const todos = yield res.json()
yield put(loadedTodos(todos))
} catch (e) {
yield put(todosFailure(e.message))
}
}
function* saveTodo (action) {
try {
const options = {
method: ‘POST’,
body: JSON.stringify(action.todo),
headers: new Headers({
‘Content-Type’: ‘application/json’
})
}
const res = yield call(fetch, ‘v1/todos’, options)
const todo = yield res.json()
yield put(addTodoSuccess(todo))
} catch (e) {
yield put(todosFailure(e.message))
}
}
function* deleteTodo (action) {
try {
yield call(fetch, `v1/todos/${action.id}`, { method: ‘DELETE’ })
} catch (e) {
yield put(todosFailure(e.message))
}
}
function* updateTodo (action) {
try {
yield call(fetch, `v1/todos/${action.id}`, { method: ‘POST’ })
} catch (e) {
yield put(todosFailure(e.message))
}
}
function* rootSaga() {
yield takeLatest(FETCH_TODOS, getAllTodos)
yield takeLatest(ADD_TODO, saveTodo)
yield takeLatest(DELETE_TODO, deleteTodo)
yield takeEvery(TOGGLE_TODO, updateTodo)
}
export default rootSaga
Step 8: Flow our new Redux actions through our UI
/* frontend/src/Todos.js */
import React, { Component } from ‘react’
import ‘bulma/css/bulma.css’
import { connect } from ‘react-redux’
import { addTodo, toggleTodo, deleteTodo, fetchTodos } from ‘./actions/todos’;
const Todo = ({ todo, id, onDelete, onToggle }) => (
<div className=”box todo-item level is-mobile”>
<div className=”level-left”>
<label className={`level-item todo-description ${todo.done && ‘completed’}`}>
<input className=”checkbox”
type=”checkbox”
checked={todo.done}
onChange={onToggle}/>
<span>{todo.description}</span>
</label>
</div>
<div className=”level-right”>
<a className=”delete level-item” onClick={onDelete}>Delete</a>
</div>
</div>
)
class Todos extends Component {
state = { newTodo: ‘’ }
componentDidMount() {
this.props.fetchTodos()
}
addTodo (event) {
event.preventDefault() // Prevent form from reloading page
const { newTodo } = this.state
if(newTodo) {
const todo = { description: newTodo, done: false }
this.props.addTodo(todo)
this.setState({ newTodo: ‘’ })
}
}
render() {
let { newTodo } = this.state
const { todos, isLoading, isSaving, error, deleteTodo, toggleTodo } = this.props
const total = todos.length
const complete = todos.filter((todo) => todo.done).length
const incomplete = todos.filter((todo) => !todo.done).length
return (
<section className=”section full-column”>
<h1 className=”title white”>Todos</h1>
<div className=”error”>{error}</div>
<form className=”form” onSubmit={this.addTodo.bind(this)}>
<div className=”field has-addons” style={{ justifyContent: ‘center’ }}>
<div className=”control”>
<input className=”input”
value={newTodo}
placeholder=”New todo”
onChange={(e) => this.setState({ newTodo: e.target.value })}/>
</div>
<div className=”control”>
<button className={`button is-success ${(isLoading || isSaving) && “is-loading”}`}
disabled={isLoading || isSaving}>Add</button>
</div>
</div>
</form>
<div className=”container todo-list”>
{todos.map((todo) => <Todo key={todo._id}
id={todo._id}
todo={todo}
onDelete={() => deleteTodo(todo._id)}
onToggle={() => toggleTodo(todo._id)}/> )}
<div className=”white”>
Total: {total} , Complete: {complete} , Incomplete: {incomplete}
</div>
</div>
</section>
);
}
}
const mapStateToProps = (state) => {
return {
todos: state.todos.items,
isLoading: state.todos.loading,
isSaving: state.todos.saving,
error: state.todos.error
}
}
const mapDispatchToProps = {
addTodo,
toggleTodo,
deleteTodo,
fetchTodos
}
export default connect(mapStateToProps, mapDispatchToProps)(Todos)
Now we have:
- A very functional UI rendered through React components
- The app state is managed through Redux
- and Async is handled through Redux Sagas, beautiful!
Step 9: Deploying our application into the Real World
We are going to be using Zeit’s Now service to get our app deployed on the internet.
- Sign up for an account at https://zeit.co/now, they have a great free tier.
- Download their client at https://zeit.co/download
- Once setup, go to our main project directory and run our build command: `npm run build`
- Add whitelisted files to `backend/.npmignore.json`: (now ignores files in .gitignore):
!build
- Go into our backend directory and run:
now
(You will need to login with your account) - Your application will start getting packaged up and deployed to Now
- Once successful, it will hand back a url where your app is now hosted
e.g https://koalerplate-flwzaruvle.now.sh - Woohoo!! We’re online! 🎉
Step 10: Celebrate!
Well done! If you’ve made it this far you’ve conquered a lot of mountains and have successfully built a full stack application. This paves the way for any project to come no matter how complex.
Now it’s time to sit back and tick off some sweet todo items… 😛
Rhys Davies
Full Stack Software Engineer
jtribe
rhys@jtribe.com.au
github.com/rhysdavies1994