Rails, React &GraphQL
GraphQL is a query language for APIs, as well as a server side runtime for executing said queries. The query language itself is universal and not tied to any frontend or backend technology. However, the server side implementations come in many flavors; in our case we're going to use the graphql-ruby gem (with rails) to parse incoming queries, make database calls, and respond with JSON. It's a full-on replacement for REST APIs, rails API controllers, and JSON serializers.
First, let's define some vocabulary I'll be using through this tutorial.
- Queries - Fetch specific data from the API. It's best practice to make queries read-only, like you would a GET request in REST. But queries are much more than just simple GETs!
- Mutations - Any modification of data on the API. Think CREATE, UPDATE, DESTROY.
- Types - Used to define datatypes, or in our case, Rails models. A type contains fields and functions that respond with data based on what's requested in a query / mutation. Types can also be static, like String or ID; these are built into the server side library.
- Fields - Represent the attributes for a given type (like attributes on a model).
- Functions - Supply the above fields with data (like methods on a model).
These 5 Things all work together to fetch, create, mangle, and destroy data in an incredibly readable and intuitive way — If you can read JSON or Yaml, you can read and write GraphQL!
Setting up a Rails API
First, we're going to create a new api-only Rails app for our backend. I'm gonna skip testing for now for the sake of this tutorial. Next, create a couple models to test data with.
rails new graphql_api --skip-test --api rails g model User email:string name:string rails g model Book user:belongs_to title:string rails db:migrate
Open app/models/user.rb and add the has_many :books association. Optionally, create some seed data using the faker gem in seeds.rb, then run rake db:seed.
Installing dependencies
# Gemfile # The ruby implementation of the GraphQL language. gem 'graphql' group :development do # A development utility to test GraphQL queries. gem 'graphiql-rails' # Seed data generator gem 'faker' end
Generating the GraphQL files
rails generate graphql:install bundle rails generate graphql:object user rails generate graphql:object book
These generators create a graphql directory with types, mutations, and a schema. We also want to generate new custom types for our User and Book models we created above.
├─ controllers + │ └─ graphql_controller.rb + ├─ graphql + │ ├─ mutations + │ ├─ rails_graphql_demo_schema.rb + │ └─ types + │ ─ base_enum.rb + │ ─ base_input_object.rb + │ ─ base_interface.rb + │ ─ base_object.rb + │ ─ base_scalar.rb + │ ─ base_union.rb + │ ─ book_type.rb + │ ─ mutation_type.rb + │ ─ query_type.rb + │ ─ user_type.rb
The generator adds a new POST endpoint to our routes that's mapped to app/controllers/graphql_controller.rb#execute — this method serves as our main API entrypoint and is ready to go. For development, we need the additional endpoint for graphiql-rails.
# routes.rb Rails.application.routes.draw do if Rails.env.development? mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "graphql#execute" end post "/graphql", to: "graphql#execute" end
Testing queries with Graphiql
The final step to get graphiql running is to uncomment require "sprockets/railtie" in application.rb. Boot up your rails server with rails s and navigate to https://localhost:3000/graphiql to see the interface. Here we can run the following query to get a test response from the API.

Types
For the User and Book models, we need to create a series of types so GraphQL knows what kind of data to send back in the event of a request. Somewhat similar to Rails' activemodelserializers or JBuilder, these types make up the structure of our models from the API's point of view. Here we'll specifiy what columns, model methods, and more return to the client application. More info on declaring types can be found here.
User and Book Types
Open up the generated types and add the following fields. Notice each field gets an "object type" and a null option of whether or not it needs to be present for the query to succeed (e.g. an :id field should never be nil, but :name might be). This tells graphql what to expect from incoming and outgoing data, and gives us peace of mind in knowing exactly how to parse data on both the front and back end.
Also notice we didn't have to define functions for :id, :name, etc; Those are automatically mapped to the Rails model's attributes we created earlier. Then, we added a custom field, books_count. This method doesn't exist on the Rails model, so we define it below the list of fields. In these methods object refers to the Rails model, so we must call object.books.size.
# app/graphql/types/user_type.rb module Types class UserType < Types::BaseObject field :id, ID, null: false field :name, String, null: true field :email, String, null: true field :books, [Types::BookType], null: true field :books_count, Integer, null: true def books_count object.books.size end end end # app/graphql/types/book_type.rb module Types class BookType < Types::BaseObject field :title, String, null: false end end
The Main Query Type
There are two main types that incoming requests are routed to: query_type.rb and mutation_type.rb. They are both already refrerenced in our schema file, and behave somewhat similarly to Rails routes & resources.
# app/graphql/RAILS_APP_NAME_schema.rb class GraphqlApiSchema < GraphQL::Schema mutation(Types::MutationType) query(Types::QueryType) end
In our main query type file, we define :users and :user fields, along with users and user functions. The users field returns an array of UserType objects, and can never be nil (but can be empty). The user field accepts a required argument :id that is of the type ID, and returns a single UserType object. (ID is a built-in type that acts just the same as the above User and Book type.)
# app/graphql/types/query_type.rb module Types class QueryType < Types::BaseObject field :users, [Types::UserType], null: false def users User.all end field :user, Types::UserType, null: false do argument :id, ID, required: true end def user(id:) User.find(id) end end end
Querying the User Fields
Visit https://localhost:3000/graphiql in your browser and paste in the following for the users and user query fields we added above. Here we specify exactly what we want the API to respond with; in this case, we only want a list of user names, emails, and the number of books they own.
query { users { name email booksCount } }
We can also query a single user, along with all of their books, and each book's title.
query { user(id: 1) { name email books { title } } }

Mutations
Mutations allow for creating, updating, and destroying data. More info on them can be found here. Let's set up a base class from which to extend a CreateUser mutation.
# app/graphql/mutations/base_mutation.rb class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation end
- Arguments - Here we specify which arguments to accept as params, which are required, and what object types they are. This is somewhat similar to defining strong params in a Rails controller, but with more fine grained control of what's coming in.
- Fields - Same concept as Query fields above. In our case, we accepted arguments to create a user, and we want to return a user field with our new model accompanied with an array of errors if present.
- Resolver - The resolve method is where we execute our ActiveRecord commands. It returns a hash with keys that match the above field names.
# app/graphql/mutations/create_user.rb class Mutations::CreateUser < Mutations::BaseMutation argument :name, String, required: true argument :email, String, required: true field :user, Types::UserType, null: false field :errors, [String], null: false def resolve(name:, email:) user = User.new(name: name, email: email) if user.save # Successful creation, return the created object with no errors { user: user, errors: [], } else # Failed save, return the errors to the client { user: nil, errors: user.errors.full_messages } end end end
Then finally, add the new mutation to the main mutation type class so it's exposed to our API.
# app/graphql/types/mutation_type.rb module Types class MutationType < Types::BaseObject field :create_user, mutation: Mutations::CreateUser end end
Creating a User
To test, open up https://localhost:3000/graphiql and paste in the following query. Notice we pass in an input: {} object to createUser; this maps to the :create_user field which accepts a single input argument. Learn more about this design in graphql-ruby's documentation.
mutation { createUser(input: { name: "Matt Boldt", email: "me@mattboldt.com" }) { user { id name email } errors } }

Success! We just created our first model via GraphQL; no extra routes, controllers, or serializers needed. What's more, we only returned exactly the data we needed from the newly created model.
The Frontend
Rails API Setup
In order to use React with rails, we need to make some changes to the API.
# Gemfile gem 'rack-cors'
Then, open up application.rb and add the following settings. This will allow us to accept AJAX requests coming from React.
# config/application.rb # CORS config to allow ajax config.middleware.insert_before 0, Rack::Cors do allow do origins '*' resource '*', headers: :any, methods: [:get, :post, :options] end end
Note: you'll want to replace * with your domain if you're in a production environment!
Extra Seed Data
I added some extra data to the Book model so we'll have more to display in our frontend app. View commit
# app/graphql/types/book_type.rb module Types class BookType < Types::BaseObject field :id, Integer, null: false field :title, String, null: false field :cover_url, String, null: true field :average_rating, Integer, null: true end end
React
In the Rails app folder, we're going to create a new react app using create-react-app. See their README for setup guides. I'm using yarn here, but you can use npm if you'd like.
create-react-app frontend cd frontend yarn start
I deleted some the css, svg, and tests generated with create-react-app, as well as created components and styles folders to better organize things. Here's what my project looks like:
├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── components │ │ └── App.js │ ├── index.js │ ├── serviceWorker.js │ └── styles │ └── index.css ├── package.json └── yarn.lock
Be sure to remove references of App.css and update the paths to point to ./components/App
// src/index.js import React from 'react'; import ReactDOM from 'react-dom'; import './styles/index.css'; import App from './components/App'; // Updated path import * as serviceWorker from './serviceWorker';// src/components/App.js import React, { Component } from 'react'; -import logo from '../logo.svg'; -import '../styles/App.css';
Styling
// src/index.html <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
For user avatars, we'll use Gravatar with yarn add react-gravatar.
Apollo
- react-apollo is a React-specific library for using Apollo in components
- apollo-boost contains many utilities and libraries for Apollo to get you set up quickly
- and finally the javascript graphql library itself
yarn add apollo-boost react-apollo graphql
Setting up ApolloClient
We need to configure ApolloClient with our API and wrap our root <App /> with the ApolloProvider higher-order component.
// src/index.js // [truncated] import { ApolloProvider } from 'react-apollo'; import { ApolloClient } from 'apollo-client'; import { createHttpLink } from 'apollo-link-http'; import { InMemoryCache } from 'apollo-cache-inmemory'; const link = createHttpLink({ uri: 'https://localhost:3000/graphql' }); const client = new ApolloClient({ link: link, cache: new InMemoryCache() }); ReactDOM.render( <ApolloProvider client={client}> <App /> </ApolloProvider>, document.getElementById('root') );
Users Index View
Now that we have the client passed down from index.js, we can start writing queries. Create a new component in src/components/Users.js and import the following:
// src/components/Users.js import React, { Component } from 'react'; import { Query } from 'react-apollo'; import gql from 'graphql-tag'; import Gravatar from 'react-gravatar';
- Query is another higher-order component that we'll wrap all of our Users index page with
- gql will help us build queries to send to the API
- Gravatar is for quick user avatars
Now we can write our query to fetch users and return id, name, email, booksCount (just like the test queries against the Rails server using graphiql!). Place it after the imports but before the class definition.
// Below imports in src/components/Users.js const USERS_QUERY = gql` query { users { id name email booksCount } } `;
And finally, we have the Users index component class with its render function. The Query component is passed the USERS_QUERY and it returns with loading states, error info (if any), and the API's response data rendered in jsx. Since this component is wrapped in Query, the API request will be sent immediately after it's rendered. Some styling from Tailwind has also been added to render users inside cards.
// Below user query in src/components/Users.js class Users extends Component { render() { return ( <Query query={USERS_QUERY}> {({ loading, error, data }) => { if (loading) return <div>Fetching..</div> if (error) return <div>Error!</div> return ( <div className="flex flex-wrap mb-4"> {data.users.map((user) => { return <div key={user.id} className="m-4 w-1/4 rounded overflow-hidden shadow-lg"> <Gravatar email={user.email} size={150} className="w-full" /> <div className="px-6 py-4"> <div className="font-bold text-xl mb-2">{user.name}</div> <p className="text-grey-darker text-base">{user.email}</p> <p className="text-grey-darker text-base">{user.booksCount} books</p> </div> </div> })} </div> ) }} </Query> ) } } export default Users;
To see this component in action, we need to call it from our app's root component App.js
// src/components/App.js import React, { Component } from 'react'; import Users from './Users'; class App extends Component { render() { return ( <div className="container mx-auto px-4"> <Users /> </div> ); } } export default App;

User Profile & Books View
Next, we'll need to create a user's profile page which includes their list of books. Since the index page only returns exactly what it needed to render, we'll need to send a new request to the API to get back all that user's data.
// src/components/User.js import React, { Fragment } from 'react'; import { Query } from 'react-apollo'; import gql from 'graphql-tag'; import UserAvatar from './UserAvatar'; import Books from './Books'; const USER_QUERY = gql` query User($id: ID!) { user(id: $id) { books { id title coverUrl averageRating } } } `;// src/components/User.js const User = ({ user, selectUser }) => ( <Query query={USER_QUERY} variables={{ id: user.id }}> {({ loading, error, data }) => { if (loading) return <div>Fetching..</div> if (error) return <div>Error!</div> return ( <Fragment> <div className="flex my-4"> <button className="bg-grey-light hover:bg-grey text-grey-darkest font-bold py-2 px-4 rounded" onClick={selectUser.bind(this, null)}> Back </button> </div> <div className="flex mb-4"> <div className="my-4 w-1/4 rounded overflow-hidden"> <UserAvatar user={user} /> </div> <div className="my-4 px-4 w-3/4"> <Books books={data.user.books} /> </div> </div> </Fragment> ) }} </Query> ); export default User;
User Avatar
In this User component, I've added a UserAvatar and a Books component to help display the data in a reusable way. I'll also refactor the user avatar code on the Users index page to use the new component.
// src/components/UserAvatar.js import React, { Fragment } from 'react'; import Gravatar from 'react-gravatar'; const UserAvatar = ({ user }) => ( <Fragment> <Gravatar email={user.email} size={150} className="w-full" /> <div className="px-6 py-4"> <div className="font-bold text-xl mb-2">{user.name}</div> <p className="text-grey-darker text-sm">{user.email}</p> <p className="text-grey-darker text-base">{user.booksCount} books</p> </div> </Fragment> ) export default UserAvatar;// .... // src/components/Users.js <div className="flex flex-wrap mb-4"> {data.users.map((user) => { return <div key={user.id} className="m-4 w-1/4 rounded overflow-hidden shadow-lg" onClick={this.props.selectUser.bind(this, user)}> <UserAvatar user={user} /> </div> })} </div> // ...
Books
// src/components/Books.js import React, { Fragment } from 'react'; const Books = ({ books }) => ( <Fragment> {books.map((book) => <div key={book.id} className="flex border-b border-solid border-grey-light"> <div className="w-3/4 p-4"> <h3>{book.title}</h3> <p className="text-grey-darker"> {[...Array(book.averageRating).keys()].map((s) => <span key={s}>★</span> )} </p> </div> <div className="w-1/4 p-4 text-right"> <img src={book.coverUrl} alt={book.title} /> </div> </div> )} </Fragment> ); export default Books;
App
Then, we must call the User component from our main App component. In here, we can hook up the action to show & hide user profiles on click. We're also storing the selected customer profile in the state object.
// src/components/App.js import React, { Component } from 'react'; import Users from './Users'; import User from './User'; class App extends Component { state = { selectedUser: null }; selectUser = (user) => { this.setState({ selectedUser: user }) } render() { return ( <div className="container mx-auto px-4"> {this.state.selectedUser ? <User user={this.state.selectedUser} selectUser={this.selectUser} /> : <Users selectUser={this.selectUser} />} </div> ); } } export default App;
Creating a New User
To create users via mutations, we need a new component in src/components/CreateUser.js. Here we will need to import the Apollo Mutation and write the query.
// src/components/CreateUser.js import React, { Component } from 'react'; import gql from "graphql-tag"; import { Mutation } from "react-apollo"; const CREATE_USER = gql` mutation CreateUser($name: String!, $email: String!) { createUser(input: { name: $name, email: $email }) { user { id name email booksCount } errors } } `;
Then, we define our component and its initial state.
// src/components/CreateUser.js class CreateUser extends Component { state = { name: '', email: '' } onSubmit = () => { // We'll implement this later } render() { return ( <Mutation mutation={CREATE_USER}> <!-- implemented later --> </Mutation> ); } } export default CreateUser;
The full mutation
<Mutation mutation={CREATE_USER} update={this.props.onCreateUser}> {createUserMutation => ( <form className="px-8 pt-6 pb-8 mb-4" onSubmit={e => this.onSubmit(e, createUserMutation)}> <h4 className="mb-3">Create new user</h4> <div className="mb-4"> <input className="border rounded w-full py-2 px-3" type="text" value={this.state.name} placeholder="Name" onChange={e => this.setState({ name: e.target.value })} /> </div> <div className="mb-6"> <input className="border rounded w-full py-2 px-3" type="email" value={this.state.email} placeholder="Email" onChange={e => this.setState({ email: e.target.value })} /> </div> <button className="bg-blue text-white py-2 px-4 rounded" type="submit"> Create </button> </form> )} </Mutation>
Submitting the form
onSubmit = (e, createUser) => { e.preventDefault(); createUser({ variables: this.state }); this.setState({ name: '', email: '' }); }
Rendering the CreateUser component
// src/components/Users.js import CreateUser from './CreateUser'; // .... <div className="flex flex-wrap mb-4"> <Fragment> {data.users.map((user) => { // truncated })} <div className="m-4 w-1/4 rounded overflow-hidden shadow-lg"> <CreateUser onCreateUser={this.updateUsers} /> </div> </Fragment> </div>
Dynamically updating the cache for the list of users on the index page
// .... // src/components/Users.js class Users extends Component { updateUsers = (cache, { data: { createUser } }) => { const { users } = cache.readQuery({ query: USERS_QUERY }); cache.writeQuery({ query: USERS_QUERY, data: { users: users.concat([createUser.user]) }, }); }

Learning all this tech at once is a lot to take in, and it's difficult to package it all together in an easy to digest way. I hope these tutorials helped point you in the right direction. Feel free to reach out if you have any questions about what I went over here!
阅读量: 664
发布于:
修改于:
发布于:
修改于: