Getting Started with GraphQL, Phoenix, and React
Margaret Williford, Former Developer
Article Categories:
Posted on
This tutorial will show you how to configure a Phoenix app that uses GraphQL and a React front end.
I find the best way to learn a new technology is to just build something. If you want to make the most of this post, don't just read through it, but follow along and try to build out the app! A few months ago, some fellow Vigets and I set off on building a social media app for pets. Having a concrete idea of something to build makes it easier to start getting your hands dirty with a new technology rather than just aimlessly reading blog posts (perhaps like you, right now...). I won't dive into that specific social media app here, instead we'll start with a simpler app for the purpose of demonstration. The app will have users, display a list of all these users, and will live reload new users added.
We'll use Phoenix on the server side to store the users in the database, and React Apollo on the front end to make calls to the database using GraphQL. We'll add the ability to add a user, and see that new user without a reload using GraphQL's subscription functionality.
Overview #
- Set it up! Use Elixir's package manager to set up a new Phoenix app.
- Migration Time Create a database table to store users.
- Don't overReact Pull in a basic React starter for the front end using Create React App.
- API consumption yum Set up Apollo, a popular JS library for consuming a GraphQL API.
- Types and Resolvers Implement a GraphQL API with the help of Absinthe, a popular Elixir library for implementing a GraphQL server.
- AND WE'RE LIVE Enable live updates for every time a user is added, without a refresh, by opening up a socket connection and taking advantage of GraphQL's subscription functionality.
Dependencies #
Before getting started, you'll need to have Elixir installed. On MacOS, I'd recommend using Homebrew and running brew install elixir
. Once Elixir's installed, install its package manager by running mix local.hex
. To install phoenix, run mix archive.install hex phx_new
. For the front end, you'll need node installed, and I recommend either nvm or asdf for managing node versions.
Phoenix #
To create a new Phoenix app called my_cool_app
, run mix phx.new my_cool_app
in the directory where you want to save the project. For Rails folks like myself, this is like rails new
. It builds out the directory structure and necessary files to get started on a Phoenix application. When that's finished, it will ask if you want to Fetch and install dependencies? [Yn]
. Hit enter to get all the necessary dependencies.
Phoenix assumes you're using Postgres for your database. If you haven't already, cd my_cool_app
, then run mix ecto.create
to create your database for this project. Ecto is a database wrapper for Elixir. Finally, in a separate tab, run mix phx.server
to run your phoenix server. Go to localhost:4000
in your favorite browser and you should see the default Phoenix page. 🎉🎉🎉
Migration #
Now let's add some users. We'll run a generator that will create both the migration file and the schema file. You'll run mix phx.gen.context Accounts User users name:string email:string
.
Your terminal should output (with a different timestamp on the migration):
* creating lib/my_cool_app/accounts/user.ex
* creating priv/repo/migrations/20190705201815_create_users.exs
* creating lib/my_cool_app/accounts.ex
* injecting lib/my_cool_app/accounts.ex
* creating test/my_cool_app/accounts_test.exs
* injecting test/my_cool_app/accounts_test.exs
So... what is all that? We created an Accounts context. A context is a module that groups functionality. In this example, we have a User model that is a part of the Accounts context. Down the road we might add Admin and SuperAdmin models that are a part of this same Accounts context. It lets you share some code among similar models and adds a layer of organization in the folder structure of your app.
Generating the User model creates a migration file for you to fill in:
defmodule MyCoolApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string
timestamps()
end
end
end
Let's modify that slightly to make the name a required field.
create table(:users) do
add :name, :string, null: false
...
end
Next, we'll take a look at the model it generated. Let's add a validation for the required name field. Update the changeset in the autogenerated lib/my_cool_app/accounts/user.ex
.
defmodule MyCoolApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
timestamps()
end
@required_fields ~w(name)a
@optional_fields ~w(email)a
def changeset(user, attrs) do
user
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
end
end
Last, we'll look at the autogenerated Accounts context. This gives us methods to access the database for users CRUD. No need to change anything here yet.
It also autogenerated tests for us -- yahoo! I won't get into specifics on testing in this tutorial, but you should be running them regularly with mix test
.
Alright, things are looking good. Run mix ecto.migrate
to run the migration. Now that we have our users table, we'll pause here and switch over to the front end.
React #
To add React on the front end, we'll use Create React App. This takes care of the build configuration for us. Run npx create-react-app client
to create a client directory that contains our front end code. cd client && yarn start
. This will open up your browser to localhost:3000
with the default React app page, informing you to "Edit src/App.js and save to reload."
Apollo #
We'll need some dependencies to use GraphQL on the front end. Open up client/package.json
and add the following packages:
{
...
"dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"apollo-boost": "^0.4.0",
"graphql": "^14.3.1",
"graphql-tag": "^2.10.1",
"react": "^16.8.6",
"react-apollo": "^2.5.6",
"react-dom": "^16.8.6",
"react-scripts": "3.0.1"
},
...
}
What is all this? GraphQL is a query language for APIs. GraphQL lets clients request the precise set of data that they need and nothing more. This blog gives a good synopsis of the pros and cons of REST vs GraphQL. GraphQL isn't really necessary in this simple user example, but this app will teach the mechanics of setting up GraphQL endpoints for where it may be more applicable. Absinthe is a popular Elixir library for implementing a GraphQL server. Apollo is a popular JS library for consuming a GraphQL API.
Let's run a cd client && yarn install
to make sure our packages are installed. Next, we'll modify client/App.js
to use the Apollo client. We'll also create a Users
component to show a list of users to display on the homepage.
import React from "react";
import { ApolloProvider } from "react-apollo";
import "./App.css";
import { createClient } from "./util/apollo";
import Users from "./Users";
function App() {
const client = createClient();
return (
<ApolloProvider client={client}>
<Users />
</ApolloProvider>
);
}
export default App;
The Users component that we reference above will live in client/src/Users.js
and look like this:
import gql from "graphql-tag";
import React from "react";
import { Query } from "react-apollo";
function Users() {
const LIST_USERS = gql`
{
listUsers {
id
name
email
}
}
`;
return (
<div>
<h1>Users!</h1>
<Query query={LIST_USERS}>
{({ loading, error, data }) => {
if (loading) return "Loading...";
if (error) return `Error! ${error.message}`;
return (
<ul>
{data.listUsers.map(user => (
<li key={user.id}>
{user.name}: {user.email}
</li>
))}
</ul>
);
}}
</Query>
</div>
);
}
export default Users;
What is Apollo doing here? Well, nothing yet. We need to create the client in client/src/util/apollo.js
. We'll keep it simple to start by telling it to use its built in in memory cache, and to make requests over HTTP. When we set up subscriptions for live reloads, this will change to include some logic for when to use HTTP vs when to use sockets.
We need to tell it where to make those requests to our backend API. By default, the Phoenix development server lives at localhost:4000
so we'll direct it there for local development. If we were going to push this to a production environment, we'd include logic around when to use the production URI. We get a few handy classes from Apollo Client that take care of most of this for us.
// client/src/util/apollo.js
import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloClient } from "apollo-client";
import { HttpLink } from "apollo-link-http";
const HTTP_URI = "http://localhost:4000";
export const createClient = () => {
return new ApolloClient({
// we will change this later when setting up the socket
link: new HttpLink({ uri: HTTP_URI }),
cache: new InMemoryCache()
});
};
Now, if you visit localhost:3000
you should see an error: Error! Network error: Failed to fetch
. The front end is looking for the GraphQL API on the back end. We haven't built that yet, though, so let's do that next.
GraphQL #
To use GraphQL with Phoenix, we need a few additional packages to mix.exs
.
...
defp deps do
[
{:absinthe, "~> 1.4"},
{:absinthe_ecto, "~> 0.1.3"},
{:absinthe_plug, "~> 1.4"},
{:absinthe_phoenix, "~> 1.4.0"},
{:cors_plug, "~> 2.0"},
...
]
end
...
The first four packages are related to Absinthe, a popular Elixir library for implementing a GraphQL server. We need the CORS plug to grant permission to the client to make requests. Run mix deps.get
to install the new dependencies. Now we can start building the API.
A GraphQL API is made of types and resolvers. We'll first build the Users type using a GraphQL schema language to represent an object you can fetch, and define all the fields that can be queried.
Under my_cool_app_web
We'll create a schema
directory to store types, and within it a account_types.ex
file.
defmodule MyCoolAppWeb.Schema.AccountTypes do
use Absinthe.Schema.Notation
use Absinthe.Ecto, repo: MyCoolApp.Repo
alias MyCoolAppWeb.Resolvers
@desc "One user"
object :user do
field :id, :id
field :name, :string
field :email, :string
field :avatar_url, :string
end
object :account_queries do
@desc "Get all users"
field :list_users, list_of(:user) do
resolve(&Resolvers.AccountResolver.list_users/3)
end
end
object :account_mutations do
field :create_user, :user do
arg(:name, non_null(:string))
arg(:email, :string)
resolve(&Resolvers.AccountResolver.create_user/3)
end
end
object :account_subscriptions do
field :user_created, :user do
end
end
end
First, we're defining a user
object as a thing that can be queried and defining what fields are available. The account_queries
object matches methods to resolvers (more on those below). Notice the role the context is playing here. If we added an Admin model, we'd include another field within the account_queries object for list_admins
or even something like list_users_and_admins
. Next is account_mutations
. In GraphQL, mutations are how the client can create, update, or delete an object. In this example, we've included the field to create a new user. Similarly, we could add additional fields for updating and deleting users. The create_user
field lets the client know that name is a required field, then maps to the appropriate resolver. Lastly, we set up account_subscriptions
to enable the live updates. Every time a user is created, that will be communicated to the client via a socket connection, and our list of users will update automatically without page reload.
Ok but what are these resolvers? #
Every field on every type is backed by a function called a resolver. A resolver is a function that returns something back to the client.
defmodule MyCoolAppWeb.Resolvers.AccountResolver do
alias MyCoolApp.Accounts
def list_users(_parent, _args, _resolutions) do
{:ok, Accounts.list_users()}
end
def create_user(_parent, args, _resolutions) do
args
|> Accounts.create_user()
|> case do
{:ok, user} ->
{:ok, user}
{:error, changeset} ->
{:error, extract_error_msg(changeset)}
end
end
defp extract_error_msg(changeset) do
changeset.errors
|> Enum.map(fn {field, {error, _details}} ->
[
field: field,
message: String.capitalize(error)
]
end)
end
end
The list_users
and create_user
methods are we referenced in the AccountTypes
module. The parent, args, resolutions params come from GraphQL itself. The underscore (_parent
and _resolutions
) is an Elixir convention to indicate unused variables. Both methods use functions from the Accounts
context (my_cool_app/accounts.ex
) that we autogenerated when we ran the users migration, and both return a tuple with the status followed by the relevant object(s) or error message. The extract_error_msg
method is taking the error received from Absinthe and translating into something more easily readable.
Lastly, we need to do a few things to configure the socket connection. We'll create a new file my_cool_app_web/channels/absinthe_socket.ex
. We can delete the autogenerated user_socket.ex
.
defmodule MyCoolAppWeb.AbsintheSocket do
use Phoenix.Socket
use Absinthe.Phoenix.Socket, schema: MyCoolAppWeb.Schema
def connect(_, socket) do
{:ok, socket}
end
def id(_), do: nil
end
We also need to modify our endpoint so the API uses the right socket and the CORS plug. So we'll strip unnecessary code out of my_cool_app_web/endpoint.ex
and just have the remaining:
defmodule MyCoolAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_cool_app
use Absinthe.Phoenix.Endpoint
socket "/socket", MyCoolAppWeb.AbsintheSocket,
websocket: true,
longpoll: false
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
plug Phoenix.CodeReloader
end
plug Plug.RequestId
plug Plug.Logger
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug CORSPlug
plug MyCoolAppWeb.Router
end
The default Phoenix router (my_cool_app_web/router.ex
) will also need to change. We need to indicate that we're using an API that uses the Absinthe plug.
defmodule MyCoolAppWeb.Router do
use MyCoolAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
scope "/" do
pipe_through :api
forward "/graphiql", Absinthe.Plug.GraphiQL, schema: MyCoolAppWeb.Schema
forward "/", Absinthe.Plug, schema: MyCoolAppWeb.Schema
end
end
We're making an API router here and noting that we can only handle requests in JSON. This routes API requests to use our Absinthe schema, rather than the default Ecto Schema. For more details on the specifics of routing in Phoenix, check out these docs.
Next we need to build out that Absinthe schema. Absinthe is the GraphQL toolkit for Elixir, so we're building out a schema that is specific to GraphQL functionality. The Absinthe packages we installed give us some handy macros and utility functions to be able to define where to look for our queries, mutations, and subscriptions.
// my_cool_app_web/schema.ex
defmodule MyCoolAppWeb.Schema do
use Absinthe.Schema
import_types(Absinthe.Type.Custom)
import_types(MyCoolAppWeb.Schema.AccountTypes)
query do
import_fields(:account_queries)
end
mutation do
import_fields(:account_mutations)
end
subscription do
import_fields(:account_subscriptions)
end
end
If we were to build out more functionality, the query, mutation, and subscription blocks would each have several import_fields
lines related to every queryable object.
That's all we need to set up the communication between front end and back end. If you visit http://localhost:3000/
the error message should be gone, but there aren't any users to see!
We can add a user by playing with the GraphiQL interface at http://localhost:4000/graphiql
. You can define a query, and pass query variables right in the GUI.
We need to add the createUser
mutation here, and pass it a new user to add to our database.
mutation CreateUser($name: String!, $email: String) {
createUser(name: $name, email: $email) {
id
name
email
}
}
Next we can pass some query variables (you may need to double click this tab to open it up) and create our first user. The query variables should be written in JSON since that's what we stated in our router that we required. {"name": "Beyonce", "email": "destinyschild4ever@yahoo.com"}
Hit the Play ▶️ button and you've added your first user to the database. Flip back to the front end site localhost:3000
, refresh, and you should see your first user listed. You've done the thing!
Live Update #
Sure, we can see users, but we have to manually refresh the page every time one is added. Who has that sort of time?! Let's build in a live reload functionality to exemplify some GraphQL coolness. Because of the socket connection it uses, GraphQL can instantly send over new or changed data and our users list will immediately reflect the change.
We'll add a Subscription component on the front end to manage subscriptions.
// client/src/Subscriber.js
import { useEffect } from "react";
const Subscriber = ({ subscribeToNew, children }) => {
useEffect(() => {
subscribeToNew();
}, []);
return children;
};
export default Subscriber;
This will make more sense once we've written the subscribeToNew
method. Basically, this component is set up to know to use that method to look for new users. So let's write that method. We'll go back to the Users component to make several changes. We'll add in a form to add a new user on the page directly, write a createUser method like we did in the GraphQL GUI, and set up the subscription.
import gql from "graphql-tag";
import React from "react";
import { Query } from "react-apollo";
import produce from "immer";
import Subscriber from "./Subscriber";
import NewUser from "./NewUser";
function Users({subscribeToNew, newItemPosition, createParams}) {
const LIST_USERS = gql`
{
listUsers {
id
name
email
}
}
`;
const USERS_SUBSCRIPTION = gql`
subscription onUserCreated {
userCreated {
id
name
email
}
}
`
return (
<div>
<h1>Users!</h1>
<Query query={LIST_USERS}>
{({ loading, error, data, subscribeToMore }) => {
if (loading) return "Loading...";
if (error) return `Error! ${error.message}`;
return (
<>
// This NewUser component is the form to create a new user, we'll build that next.
<NewUser params={createParams} />
<Subscriber subscribeToNew={() =>
subscribeToMore({
document: USERS_SUBSCRIPTION,
updateQuery: (prev, { subscriptionData }) => {
// if nothing is coming through the socket, just use the current data
if (!subscriptionData.data) return prev;
// something new is coming in!
const newUser = subscriptionData.data.userCreated;
// Check that we don't already have the user stored.
if (prev.listUsers.find((user) => user.id === newUser.id)) {
return prev;
}
return produce(prev, (next) => {
// Add that new user!
next.listUsers.unshift(newUser);
});
},
})
}>
<ul>
{data.listUsers.map(user => (
<li key={user.id}>
{user.name}: {user.email}
</li>
))}
</ul>
</Subscriber>
</>
);
}}
</Query>
</div>
);
}
export default Users;
We referenced a new user component, but still need to build that. I should note that we could just keep everything within this Users.js
file, but it makes for more maintainable code to pull something like this out into a separate component.
// client/src/NewUser.js
import React, { useState} from "react";
import gql from "graphql-tag";
import { Mutation } from "react-apollo"
const CREATE_USER = gql`
mutation CreateUser($name: String!, $email: String) {
createUser(name: $name, email: $email) {
id
}
}
`;
const NewUser = ({ params }) => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const mutation = CREATE_USER;
return (
<Mutation mutation={mutation}
onCompleted={() => {
setName("");
setEmail("");
}}
>
{(submit, { data, loading, error }) => {
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit({ variables: { name, email } });
}}
>
<input
name="name"
type="text"
placeholder="What's your name?"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
name="email"
type="text"
placeholder="What's your email?"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input type="submit" value="Add" />
</form>
);
}}
</Mutation>
);
};
export default NewUser
Visit localhost:3000
and you should see the new user form at the top of the page. Enter in a user, an email, and hit add. Refresh the page, and your new user should appear.
What? No live reload? We reference the user subscription, but haven't actually written that method. Looking back at my_cool_app_web/schema/account_types.ex
, we can see that the account_subscriptions block is empty. Let's fix that.
defmodule MyCoolAppWeb.Schema.AccountTypes do
....
object :account_subscriptions do
field :user_created, :user do
config(fn _, _ ->
{:ok, topic: "users"}
end)
trigger(:create_user,
topic: fn _ ->
"user"
end
)
end
end
end
The syntax is a little funky, but this is just setting up a subscription on the user object that is triggered every time the create user method is called.
Sockets #
The last step is to set up the socket connection for the requests. The Absinthe docs are our friend here. Following along their setup instructions, we add one file to create the socket link client/src/util/absinthe-socket-link.js
import * as AbsintheSocket from "@absinthe/socket";
import {createAbsintheSocketLink} from "@absinthe/socket-apollo-link";
import {Socket as PhoenixSocket} from "phoenix";
export default createAbsintheSocketLink(AbsintheSocket.create(
new PhoenixSocket("ws://localhost:4000/socket")
));
Next, we need to tell Apollo to use this socket. Until now, we had just told Apollo to use HTTP. We'll make changes to client/src/util/apollo.js
to use our socket connection for subscriptions, and HTTP for everything else.
import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloClient } from "apollo-client";
import { HttpLink } from "apollo-link-http";
import absintheSocketLink from "./absinthe-socket-link"
import { split } from "apollo-link";
import { hasSubscription } from "@jumpn/utils-graphql";
const HTTP_URI = "http://localhost:4000";
const link = split(
operation => hasSubscription(operation.query),
absintheSocketLink,
new HttpLink({ uri: HTTP_URI })
);
export const createClient = () => {
return new ApolloClient({
link: link,
cache: new InMemoryCache()
});
};
Home stretch!! Just two more configuration steps. We need to add Phoenix as a dependency to the front end, as its a dependency of Absinthe and we're now referencing it on the front end to set up the socket connection. Add "phoenix": "^1.4.3",
to the dependencies in client/package.json
, run yarn install
from the client directory, and recompile with yarn start
.
Lastly, we need to configure a Supervisor on the backend to follow the subscriptions. We need to add a few lines to my_cool_app/lib/application.ex
.
...
def start(_type, _args) do
import Supervisor.Spec
# List all child processes to be supervised
children = [
# Start the Ecto repository
MyCoolApp.Repo,
# Start the endpoint when the application starts
MyCoolAppWeb.Endpoint,
# Starts a worker by calling: MyCoolApp.Worker.start_link(arg)
# {MyCoolApp.Worker, arg},
supervisor(Absinthe.Subscription, [MyCoolAppWeb.Endpoint])
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: MyCoolApp.Supervisor]
Supervisor.start_link(children, opts)
end
...
Restart your Phoenix server, visit localhost:3000
and add a new user. No refresh, no problem!
Next Steps #
You just set up a Phoenix app, built a users table, added a users endpoint on a GraphQL server, created some users, and rendered those users on the front end with React Apollo. Could life get any better? Probably not. But this code sure could.
For starters, it's not a great idea to put gql query string templates in the React function body because they get evaluated every time. What if things like LIST_USERS
and USERS_SUBSCRIPTION
in the Users component were hoisted to the module level? And on the backend, this needs some serious test coverage. This [2 minute read] (https://medium.com/@cam_irmas/happy-hour-testing-phoenix-absinthe-graphql-apis-46f5bb2c0379) is a good intro to testing Phoenix and GraphQL APIs.
You've just set up a subscription using GraphQL, Phoenix, and React. I am proud. Now go forth and build something even cooler.
P.S. Now that you've made it to the end, checkout the example code here.