Coding Artist
Published in

Coding Artist

Full-Stack React With Phoenix (Chapter 8 | User Authentication)

Table of Contents

Chapter 1 | Why Bother?
Chapter 2 | Learning the Basics of Elixir
Chapter 3 | Introduction to Phoenix
Chapter 4 | Implementing React
Chapter 5 | Working With PostgreSQL
Chapter 6 | Creating a PostgreSQL API Service
Chapter 7 | CRUD Operations

Scope of This Chapter

In the previous chapter, we were able to create an API service with Phoenix. This is at the heart of full-stack development. However, we didn’t cover the critical use case of doing user authentication. We will be doing another mini-project so cover how to do this.

Getting Started

While I could just provide a template, let’s walk through the creation of our new project for extra practice.

Setup

First, let’s create a new project called phoenix_user_authenticaion and enter the directory:

mix phoenix.new phoenix_user_authentication
cd phoenix_user_authentication

Next, go ahead and open the project in a code editor of choice. Check the configuration the PostgreSQL database that we will create in config/dev.exs.

Here is my configuration:

# Configure your database
config :phoenix_user_authentication, PhoenixUserAuthentication.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "phoenix_user_authentication_dev",
hostname: "localhost",
pool_size: 10,
port: 5432 # I added this, 5432 is default

If it looks good, go ahead and run mix ecto.create to create this database.

If you open pgAdmin and refresh our Phoenix server, you can double-click on the database and connect to it:

Cool.

Creating a Users Table

We want to create a table in our database to store users:

Users 
_____

username | password_hash
123...

We will make this really simple and only store a username and password_hash (encrypted password) field within a table called Users.

Let’s go ahead and create a model for this table under web/models and name it users.ex:

defmodule PhoenixUserAuthentication.Users do
use PhoenixUserAuthentication.Web, :model
schema "users" do
field :username, :string
field :password_hash, :string
timestamps()
end
end

Now, we can run mix ecto.gen.migration users to create the boilerplate for a migration file for our users table.

Now, open up the generated migration file found under priv/repo/migrations:

defmodule PhoenixUserAuthentication.Repo.Migrations.Users do
use Ecto.Migration
def change do end
end

Recall, migration files used to create tables in a database following the shape specified in a schema. Therefore, let’s update the change function like so:

defmodule PhoenixUserAuthentication.Repo.Migrations.Users do
use Ecto.Migration
def change do
create table(:users) do
add :username, :string, null: false
add :password_hash, :string, null: false
timestamps()
end
end
end

We add create table(:users) which is a function to create a table called users and adds a username and password_hash file with their type. I also included null: false as these fields can’t be null.

We are now ready to execute our migration to create our users table in our database by running the following:

mix ecto.migrate

Let’s see if it worked!

Refresh our Phoenix server in pgAdmin:

Expand our schema under Databases/phoenix_user_authentication_dev/schemas/public:

Underneath tables, we can see that the users table and a table with meta data of our migrations were both added:

If we view the users table, we can see our table has the fields as specified in our schema:

Awesome!

We need to make two quick edits before we manually enter in a row.

We need our timestamp columns (inserted_at and updated_at) to be populated automatically using the following sql expression: now()

Right-click inserted_at and click properties.

Select the definition tab and add now() as the default value:

Repeat this same process for updated_at.

Creating Our API Service

Before we get into the user authentication stuff, let’s go ahead and setup a basic api service for fetching all users and creating a new user.

Open web/router.ex and let’s add the HTTP request type, route, controller, and function to call within the controller for each of our interactions:

scope "/api", PhoenixUserAuthentication do
pipe_through :api
get "/users", UsersController, :index
post "/users", UsersController, :create
end

We want the /api route to pass through our api pipeline. On the scope of the /api path, we will have /users.

/api/users will handle a GET and POST request. On a GET request, the index function will be called within UsersController. On a POST request, the create function will be called within the UsersController.

Next, let’s create a file called users_controller.ex within the controllers folder for our UsersController.

The shell of our code looks like this:

defmodule PhoenixUserAuthentication.UsersController do
use PhoenixUserAuthentication.Web, :controller
alias PhoenixUserAuthentication.Users def index(conn, _params) do
# get all users
# render JSON object containing all users
end
def create(conn, %{"users" => users_params}) do
# create changeset
# use changeset to insert a new row using params
end
end

We create an alias so we don’t have to write out PhoenixUserAuthentication.Users when referencing the Users table.

Then, we have the shell of the index and create functions.

The index function will use Repo.all to fetch all rows in the Users table and invoke the rendering of a JSON object containing those users:

def index(conn, _params) do
users= Repo.all(Users)
render conn, "index.json", users: users
end

The create function will get a changeset which can be used to insert a new row into the Users table The changeset will be created using parameters from the HTTP request.

def create(conn, %{"users" => users_params}) do
changeset = Users.changeset(%Users{}, users_params)
case Repo.insert(changeset) do
{:ok, _blogs} ->
users = Repo.all(Users)
render conn, "index.json", users: users
end
end

On a successful insertion, all of the users will be rendered so we can see the added user.

Since we reference the changeset here, let’s create that next.

The changeset function will go in our model found in web/models/users.ex:

defmodule PhoenixUserAuthentication.Users do
use PhoenixUserAuthentication.Web, :model
schema "users" do
field :username, :string
field :password_hash, :string
timestamps()
end
def changeset(struct, params) do
struct
|> cast(params, [:username, :password_hash])
|> validate_required([:username, :password_hash])
|> unique_constraint(:username)
end
end

The changeset function takes an empty struct and casts the parameters and validates the required fields. In addition, it makes sure the usernames stay unique.

We will have to add more for our user authentication stuff but let’s leave it be for now.

To finish up our API service without user authentication stuff, let’s create the view to render a JSON object with all the users.

Create a new file called users_view.ex in web/views.

defmodule PhoenixUserAuthentication.UsersView do
use PhoenixUserAuthentication.Web, :view
#show multiple users
def render("index.json", %{users: users}) do
%{
users: Enum.map(users, &users_json/1)
}
end
def users_json(user) do
%{
username: user.username
}
end
end

Here, we render a JSON object called index that will ultimately contain an array called users that has an object containing the information for each user in every index.

Password Hashing

I’ve mentioned that we have to do user authentication stuff. You may be thinking: “What kind of stuff do we need to do?”

When working with passwords of users, we don’t want to store the exact password as a string within the database for security reasons. Instead, we need to hash the password. Hashing a password means taking the password string and applying an algorithm that will generate a completely different value.

The hashed value will be the same every time, so you can store the hashed password in a database and check the user’s entered password to the stored hash. [1]

With Phoenix, we can use a password hashing library called Comeonin.

We need to add this as a dependency in mix.exs (the Elixir equivalent of package.json). This will occur in two places.

First, we add it in the application function:

def application do
[mod: {PhoenixUserAuthentication, []},
applications: [
:phoenix, :phoenix_pubsub,
:phoenix_html, :cowboy, :logger, :gettext,
:phoenix_ecto, :postgrex, :comeonin
]
] #comeonin added here
end

We also add it our deps:

defp deps do
[{:phoenix, "~> 1.2.4"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.6"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:comeonin, "~> 2.6"}
]
end

Let’s install this new dependency using mix deps.get.

With Comeonin installed, we can go back to our model in web/models/user.ex.

We want to add a virtual password which means we can use this field in our model without it being stored in our database:

schema "users" do
field :username, :string
field :password_hash, :string
field :password, :string, virtual: true
timestamps()
end

We also update changeset to validate the username and password then hash the password by calling put_password_hash:

def changeset(struct, params) do
struct
|> cast(params, [:username, :password])
|> validate_required([:username, :password])
|> unique_constraint(:username)
|> validate_length(:password, min: 6, max: 100)
|> put_password_hash
end

put_password_hash will check apply a hash to the password in the changeset using Comeonin.Bcrypt.hashpwsalt:

defp put_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
_ ->
changeset
end
end

Now, this line in the create function of our UsersController will return a username and hashed password to insert into our database:

changeset = Users.changeset(%Users{}, users_params)

Let’s create a React frontend with a signup form to make sure our database is adding new users via our API.

Creating a Signup Form

We’ve already created a form in a React frontend and gone over all the setup. Again, I will repeat this in place of providing a template for extra practice.

First, we can install our main dependencies:

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

Next, we configure our brunch-config.js file to apply presets for Babel and React:

plugins: {
babel: {
presets: ["es2015", "react"],
// Do not use ES6 compiler in vendor code
ignore: [/web\/static\/vendor/]
}
},

In addition, we add a whitelist in under the npm options in this file so that it is clear that we are going to use react and react-dom:

npm: {
enabled: true,
whitelist: ["phoenix", "phoenix_html", "react", "react-dom"]
}

We can then create the target for our app in the index.html.eex template (delete everything else):

<div id="app"></div>

The app template contains a pre-populated header which we can remove:

<header class="header">
<nav role="navigation">
<ul class="nav nav-pills pull-right">
<li><a href="http://www.phoenixframework.org/docs">Get Started</a></li>
</ul>
</nav>
<span class="logo"></span>
</header>
^^^ delete this

In web/static/js, let’s make a complete React project directory:

We just add containers and presentationals folders. Containers refer to active components where the API request will be made and the retrieved data will be passed down to a presentational component which presents something to our UI using the inherited props.

Open app.js and let’s add the following code:

import "phoenix_html";
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router, Route, Link } from 'react-router-dom';
import Signup from "./containers/Signup";
class App extends React.Component {
render() {
return (
<Router>
<div>
<Route exact path="/" component={Signup}/>
</div>
</Router>
)
}
}
ReactDOM.render(
<App/>,
document.getElementById("app")
)

In the code above, we are going to display a signup container on the default path for now. Let’s create this container.

Create a file called Signup.js under the containers folder:

import React from "react";
import axios from "axios";
class Signup extends React.Component {
constructor() {
super();
this.state = {
username: '',
password: ''
};
}
handleUsername(event) {
this.setState({ username: event.target.value })
}
handlePassword(event) {
this.setState({ password: event.target.value })
}
handleSubmit (event) {
event.preventDefault();
console.log(this.state);
}
render() {
return (
<form onSubmit={this.handleSubmit.bind(this)}>
<div className="field">
<label className="label">Username</label>
<div className="control">
<input
className="input"
type="text"
value = {this.state.username}
onChange = {this.handleUsername.bind(this)}
/>
</div>
</div>
<div className="field">
<label className="label">Password</label>
<div className="control">
<input
className="input"
type="text"
value = {this.state.password}
onChange = {this.handlePassword.bind(this)}
/>
</div>
</div>
<button
type="submit"
value="Submit"
className="button is-primary"
>
Submit
</button>
</form>
)
}
}
export default Signup

The code above is just like the form we created in the previous chapter. It is using Bulma for predefined form styling and managing the values of each input via event handlers that update the local state.

Let’s try this signup form which currently will just log the local state on a submission.

Before we do that, however, let’s quickly add a CDN within the head of web/templates/app.html.eex:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.4.4/css/bulma.css">

Now, run brunch build and mix phoenix.server then go to http://localhost:4000/:

Insert a username and password and hit submit:

As expected, the username and password inputs are being stored in the local state.

Let’s update the handleSubmit event handler in the Signup container found in Signup.js:

handleSubmit (event) {
event.preventDefault();
axios({
method: 'post',
headers: {"Content-Type": "application/json"},
url: 'http://localhost:4000/api/users',
data: {
users: {
username: this.state.username,
password: this.state.password
}
}
});
}

We replace the log to our console with some axios code to make the HTTP POST request on http://localhost:4000/api/users with parameters in the body.

Run brunch build and submit a new username and password from the form.

Now, we can refresh and view our users table to see the new row with a hashed password:

Sweet! Next, we will create the login form and compare the inputs with the database to authenticate a user.

User Authentication at Login

Creating a Login Form in React

Open web/static/js/app.js and let’s update it:

import Signup from "./containers/Signup";
import Login from "./containers/Login";
class App extends React.Component {
render() {
return (
<Router>
<div>
<Route exact path="/" component={Signup}/>
<Route path="/login" component={Login}/>
</div>
</Router>
)
}
}

We have imported a new container component called Login and added a route for it.

Create Login.js in the containers folder to define this component:

import React from "react";
import axios from "axios";
class Login extends React.Component {
constructor() {
super();
this.state = {
username: '',
password: ''
};
}
handleUsername(event) {
this.setState({ username: event.target.value })
}
handlePassword(event) {
this.setState({ password: event.target.value })
}
handleSubmit (event) {
event.preventDefault();
//authenticate user
}
render() {
return (
<form onSubmit={this.handleSubmit.bind(this)}>
<div className="field">
<label className="label">Username</label>
<div className="control">
<input
className="input"
type="text"
value = {this.state.username}
onChange = {this.handleUsername.bind(this)}
/>
</div>
</div>
<div className="field">
<label className="label">Password</label>
<div className="control">
<input
className="input"
type="text"
value = {this.state.password}
onChange = {this.handlePassword.bind(this)}
/>
</div>
</div>
<button
type="submit"
value="Submit"
className="button is-primary"
>
Submit
</button>
</form>
)
}
}
export default Login

This form is just like our signup form except we need to do something else on a submission:

handleSubmit (event) {
event.preventDefault();
//authenticate user
}

Let’s also add a button to link to the login form from the signup form by adding the following to Signup.js:

import React from "react";
import axios from "axios";
import { Link } from 'react-router-dom';
class Signup extends React.Component {
constructor() {
super();
this.state = {
username: '',
password: ''
};
}
handleUsername(event) {
this.setState({ username: event.target.value })
}
handlePassword(event) {
this.setState({ password: event.target.value })
}

handleSubmit (event) {
event.preventDefault();
// authenticate user
}
render() {
return (
<div>
<form onSubmit={this.handleSubmit.bind(this)}>
<div className="field">
<label className="label">Username</label>
<div className="control">
<input
className="input"
type="text"
value = {this.state.username}
onChange = {this.handleUsername.bind(this)}
/>
</div>
</div>
<div className="field">
<label className="label">Password</label>
<div className="control">
<input
className="input"
type="text"
value = {this.state.password}
onChange = {this.handlePassword.bind(this)}
/>
</div>
</div>
<button
type="submit"
value="Submit"
className="button is-primary"
>
Submit
</button>
</form>
<Link
className="button is-info"
to="/login"
>
Login
</Link>
</div>
)
}
}
export default Signup

We now have our signup and login forms rendering and routing correctly:

Editorial Note: I made some slight changes after this screenshot so styling of buttons will be slightly different.

Next, we need to add the functionality of authenticating a user on login by comparing the username and password with the username and password hash in the database.

Authentication Using Guardian

To do this, we are going to use a library called Guardian.

First, we need to add Guardian to our application and as a dependency in mix.exs:

def application do
[mod: {PhoenixUserAuthentication, []},
applications: [
:phoenix, :phoenix_pubsub,
:phoenix_html, :cowboy, :logger, :gettext,
:phoenix_ecto, :postgrex, :comeonin, :guardian
]
]
end
defp deps do
[{:phoenix, "~> 1.3.0-rc"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.6"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:comeonin, "~> 2.6"},
{:guardian, "~> 0.14"}
]
end

Then, we get this dependency by running:

mix deps.get

Open config/config.exs and let’s add some configuration code:

# Configures Guardian
config :guardian, Guardian,
issuer: "PhoenixUserAuthentication",
ttl: {30, :days},
verify_issuer: true,
serializer: PhoenixUserAuthentication.GuardianSerializer

We also need to add a secret key which we can generate using mix phoenix.gen.secret:

# Configures Guardian
config :guardian, Guardian,
issuer: "PhoenixUserAuthentication",
ttl: {30, :days},
verify_issuer: true,
serializer: PhoenixUserAuthentication.GuardianSerializer,
secret_key: "syR5soTnDOp25yxyU4Y4rL/r0j4v/MNy/5l2gnjRcMwCx/UlWO88C28lWI0UrgJP"
# update for your generated secret key

If you are curious about this configuration, you can check the official documentation. The only thing that concerns us at the moment is to create the serializer which we specified at PhoenixUserAuthentication.GuardianSerializer.

Create a new file at lib/phoenix_user_authentication called guardian_serializer.ex:

defmodule PhoenixUserAuthentication.GuardianSerializer do
@behaviour Guardian.Serializer
alias PhoenixUserAuthentication.Repo
alias PhoenixUserAuthentication.Users
def for_token(users = %Users{}), do: {:ok, "Users:#{users.id}"}
def for_token(_), do: {:error, "Unknown resource type"}
def from_token("Users:" <> id), do: {:ok, Repo.get(Users, String.to_integer(id))}
def from_token(_), do: {:error, "Unknown resource type"}
end

Guardian uses JSON web tokens to perform authentication. I highly recommend reading an introduction on JSON web tokens here.

At a high level, you can think of possessing a token as authenticating a user during a session and allowing them access to our application. This is similar to possessing a token to have access to play arcade games. The code above can be thought of as giving and removing a token for a single user.

We need to keep track of a user’s possession of a token in a session at different points of our application. In our case, this will be logging in and logging out. To do this, we will use a separate controller called SessionsController with different functions to do things on those different points of our application.

To trigger these functions in SessionsController, we need to add some API routes which we can do in web/router.ex:

scope "/api", PhoenixUserAuthentication do
pipe_through :api
post "/sessions", SessionsController, :create # login
delete "/sessions", SessionsController, :delete # log out
get "/users", UsersController, :index
post "/users", UsersController, :create
end

We also need to add some plugs to our api pipeline:

pipeline :api do
plug :accepts, ["json"]
plug Guardian.Plug.VerifyHeader, realm: "Bearer"
plug Guardian.Plug.LoadResource
end

plug Guardian.Plug.VerifyHeader, realm: “Bearer” looks for a token in the header and validates it. If there is a token, the user will be loaded so we can check permissions and authenticate accordingly from SessionsController. If a token can’t be verified, nothing happens.

Let’s start to write the functions within SessionsController as we specified in our /sessions routes for authenticating at login and logout.

Create a file called session_controller.ex under web/controllers.

First, we can add the create function:

defmodule PhoenixUserAuthentication.SessionsController do
use PhoenixUserAuthentication.Web, :controller
alias PhoenixUserAuthentication.Users def create(conn, %{"username" => username, "password" => password}) do
case authenticate(%{"username" => username, "password" => password}) do
{:ok, user} ->
new_conn = Guardian.Plug.api_sign_in(conn, user, :access)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
:error ->
conn
|> put_status(:unauthorized)
end
end
defp authenticate(%{"username" => username, "password" => password}) do
user = Repo.get_by(Users, username: username)
case check_password(user, password) do
true -> {:ok, user}
_ -> :error
end
end
defp check_password(nil, _), do: Comeonin.Bcrypt.dummy_checkpw()
defp check_password(user, password) do
Comeonin.Bcrypt.checkpw(password, user.password_hash)
end
end

When a POST HTTP request is made on the /api/sessions route (which will occur on a submission from our login form), we call a function called authenticate which gets a user row by username using the username parameter and checks the password parameter with the stored password. If there’s a match, then {:ok, user} is returned.

On a return of {:ok, user}, we create a new connection where the user is authenticated via Guardian.Plug.api_sign_in(conn, user, :access). We also get the current JSON web token. We then put a status of “created”. On the case of an error, we put a status of “unauthorized”.

When a user logs out and a DELETE HTTP request is made on the /api/sessions route, we want to revoke the token that is granting them access:

def delete(conn, _params) do
jwt = Guardian.Plug.current_token(conn)
Guardian.revoke(jwt)
conn
|> put_status(:ok)
end

When a sign up occurs via a POST HTTP request on /api/users, we want to also authenticate the user so they don’t have to login. Therefore, let’s update our create function in users_controller.ex:

def create(conn, %{"users" => users_params}) do
changeset = Users.changeset(%Users{}, users_params)
case Repo.insert(changeset) do
{:ok, _} ->
user = Repo.get_by(Users, username: username)
new_conn = Guardian.Plug.api_sign_in(conn, user, :access)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
end
end

Now, we are not only inserting a new row into our database but authenticating the user via Guardian.Plug.api_sign_in(conn, user, :access) as well as storing the JSON web token (just like we did when handling a login).

The final step on the server-side is to send a JSON object containing data for the current user and the JSON web token. We do this by adding a session view in web/views/sessions_view.ex.

In this file, we will return a JSON object containing two objects within it called data and meta.

defmodule PhoenixUserAuthentication.SessionsView do
use PhoenixUserAuthentication.Web, :view
def render("show.json", %{user: user, jwt: jwt}) do
%{
data: user_data_json(user),
meta: meta_json(jwt)
}
end
def user_data_json(user) do
%{
id: user.id,
username: user.username
}
end
def meta_json(jwt) do
%{
token: jwt
}
end
end

data contains the id and username for the user and meta contains the token.

Let’s invoke the rendering of show.json within the SessionsController after a new connection:

def create(conn, %{"username" => username, "password" => password}) do
case authenticate(%{"username" => username, "password" => password}) do
{:ok, user} ->
new_conn = Guardian.Plug.api_sign_in(conn, user, :access)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
|> render("show.json", user: user, jwt: jwt) # new insertion
:error ->
conn
|> put_status(:unauthorized)
end
end

We also want to do this for the login happening in UsersController:

def create(conn, %{"users" => users_params}) do
changeset = Users.changeset(%Users{}, users_params)
case Repo.insert(changeset) do
{:ok, user} ->
new_conn = Guardian.Plug.api_sign_in(conn, user, :access)
jwt = Guardian.Plug.current_token(new_conn)
new_conn
|> put_status(:created)
|> render(PhoenixUserAuthentication.SessionsView, "show.json", user: user, jwt: jwt)
end
end

Instead of writing another view, we can just add PhoenixUserAuthentication.SessionsView before the invoking of show.json in session_view.ex.

I know! Authentication is a pain first time around but we are almost done!

Let’s try logging in and seeing if the user is authenticated by updating web/static/js/containers/Login.js to make an HTTP request on submission:

handleSubmit (event) {
event.preventDefault();
axios({
method: 'post',
headers: {
"Content-Type": "application/json",
},
url: 'http://localhost:4000/api/sessions',
data: {
username: this.state.username,
password: this.state.password
}
})
.then((response) => {
console.log(response);
});
}

Run brunch build and try logging in using the same username and password when creating a user:

After you submit, check the response which we had logged in the console:

We can see that it has retrieved the user from the database and provided a token!

Now, you could use the response to let React know that the user is authenticated and redirect a user to a client-side route that requires authentication for access. This falls outside the scope of this chapter, but you can see this helpful guide.

To wrap up, let’s also check to see if a user is being authenticated when signing up as should occur given the code in users_controller.ex.

To make sure it’s working, we can log the response from an HTTP request at signup in web/static/js/containers/Signup.js:

handleSubmit (event) {
event.preventDefault();
axios({
method: 'post',
headers: {"Content-Type": "application/json"},
url: 'http://localhost:4000/api/users',
data: {
users: {
username: this.state.username,
password: this.state.password
}
}
})
.then((response) => {
console.log(response);
});
}

Run brunch build go to http://localhost:4000/, and sign up:

Check the console and we should see the following:

Woot woot! The user was authenticated but was it created in our database?

You bet yah!

Final Code

Available on GitHub.

Concluding Thoughts

Authentication is quite the beast for only being a sliver of an application. However, it’s one of those things that you learn once and can reuse over and over.

We now know the heart of full-stack web development which includes interacting with a database to populate our UI and authenticate users.

There are only a few things left to cover in this book. In the next chapter, we will be looking into bi-directional communication using Phoenix’s built-in channels.

Chapter 9

Chapter 9 is now available.

Sign Up for Notifications

Get notified when each chapter is released.

Cheers,
Mike Mangialardi
Founder of Coding Artist

--

--

--

Providing journeys for developers who see the web as their canvas

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Michael Mangialardi

Michael Mangialardi

UI Developer in Southwest Virginia. Soli Deo Gloria. https://coding-artist.teachable.com

More from Medium

Controlling React API Calls With Hooks

Next JS : Basic features

React Dropzone and upload images Part 9 Render the data from firestore

Routing at Next.js vs React.js