How to build a GraphQL App with Fetch
2 votes
338 views
by Can Göktas

Introduction

Have you ever wondered why GraphQL clients are so popular? What problems are libraries like Relay or Apollo Client solving? One can always call GraphQL APIs directly with HTTP requests, right? Before using an abstraction and installing a client library, it's helpful to understand the lower level concepts first.

This is what we will do in this tutorial. We're going to get ourselves comfortable with talking with GraphQL APIs using plain HTTP requests. Building out a simple to-do app in React will help us go through common GraphQL problems and understand the gotchas that we need to deal with when we aren't using a GraphQL client library.

Once we've experienced a world without help from client libraries, we'll understand their value much better, and it will allow us to make better-informed decisions about when and why to use them.

Prerequisites

The tutorial assumes that you have some basic familiarity with:

  • GraphQL
  • React
  • Render props, a technique for sharing code between React components.

This tutorial also assumes that you have Docker and Node.js installed.

This tutorial uses Docker to run a local database with Prisma. If you prefer to not use Docker, you can use a hosted and free demo database in Prisma Cloud:

npm install -g prisma
git clone https://github.com/cangoektas/prisma-react-fetch
cd prisma-react-fetch
cd server
prisma deploy --new
# Select the *Demo server* when prompted by the interactive CLI wizard
Copy

Once you have completed these steps, you can continue on to the section on checking the query in the playground — the last code block in the Set up demo GraphQL API section.

If you have, you can check if Docker is installed correctly by running

docker --version
Copy

The output of that command should be something like Docker version 18.09.0, build 4d60db4. You can do the same for Node.js:

node --version
Copy

This should give you the Node.js version that you have installed.

Set up demo GraphQL API

Throughout this tutorial, we're going to use Prisma to spin up a demo GraphQL API that we can consume from our React app. To set up the server for this tutorial, install the prisma CLI globally.

npm install -g prisma
Copy

Please note that you should only access a Prisma GraphQL API from frontend for prototyping and learning purposes. The primary use case for the Prisma GraphQL API is to be consumed via the Prisma client as a replacement for traditional ORMs in your API server.

After that, clone the Git repository that contains the set up files we'll need for our project:

git clone https://github.com/cangoektas/prisma-react-fetch
Copy

In our demo project, a to-do app, you'll find three directories:

  • server: A ready to use Prisma Graph API for managing to-do items
  • app: A React app boilerplate that we're going to build on in this tutorial
  • app-final: The final version / solution of our React app that you can refer to at any time

Navigate into the server directory and bring up the necessary Docker containers with Docker Compose:

cd prisma-react-fetch
cd server
docker-compose up -d
Copy

This will build and start the MySQL and Prisma Docker containers.

If you run into issues starting up or using Docker Compose, you can refer to this troubleshooting guide, for a quick reference of some helpful commands.

Finally, deploy our Prisma service with:

prisma deploy
Copy

After that, we can verify that our Prisma service is running by visiting http://localhost:4466 in the browser (it should open up the GraphQL Playground).

Pasting in the following query and executing it should display three to-do items:

query {
  todoItems {
    id
    text
    completed
  }
}
Copy

If you see a result with three to-do items, you did everything right, and you are ready for the next step!

Getting started with React

The easiest way to get started with React is using a tool called Create React App. Our demo project is already configured to use it.

In our demo project, there's an app directory which is the React application that we're going to be focusing on. Navigate to the app directory, install the dependencies first with

cd ../app
npm install
Copy

After that, you can start the development server with:

npm start
Copy

That's it! If everything went well, you should see the text "To-do items" in your browser.

GraphQL over HTTP

Most GraphQL servers support HTTP by default. In its essence, there are two ways to talk to a GraphQL API using HTTP: Using GET or POST requests. For both, there's a way to include a GraphQL query or mutation string, as well as optional variables. This data is included as query parameters when making GET requests, or in the request payload when making POST requests.

In any case, the GraphQL API is expected to return a JSON object containing a data property and, possibly, an errors property, if there were any errors.

The "Serving over HTTP" section on graphql.org is a great resource that explains all this in great detail.

In this tutorial, we're going to stick to POST requests.

Querying data using fetch

The Fetch API is a Promise-based browser API for making HTTP requests. It's supported in most modern browsers, and we can use it through the global fetch function. Let's get started by querying to-do items from our Prisma server!

First, we're going to need some state in our App component to store the queried data. Let's update our App component in App.js:

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    loading: false,
    data: null
  };

  render() {
    // as before
  }
}

export default App;
Copy

Then, we're going to be using the lifecycle method componentDidMount to fetch data as soon as possible.

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    loading: false,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: `query { todoItems { id text completed } }`
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          this.setState({ loading: false, data: responseAsJson.data });
        });
    });
  };

  render() {
    // as before
  }
}

export default App;
Copy

A lot is happening here, so let's try to understand what's going on. The first argument to the fetch call is the address of our local Prisma server. In the second argument, we're using an object to configure the request we're going to make. You can read more about the usage of the fetch function here.

  1. With the method property, we're telling the browser that we want to make a POST request.
  2. With the headers property, we're setting the Content-Type header for the request, so that our Prisma server knows how to parse the request payload.
  3. With the body property, we're setting the request payload, which contains an object with a query property set to our GraphQL query string.
  4. The body object needs to be serialized for the request body with JSON.stringify(). Similarly, the response body needs to be parsed back into a JSON object. We do that using the response.json() method.
  5. Once we have the response body as JSON, we update our App components state.

Now, we can change our render method to display the received to-do items. Below you can now see the full App.js file.

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    loading: false,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: `query { todoItems { id text completed } }`
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          this.setState({ loading: false, data: responseAsJson.data });
        });
    });
  };

  render() {
    if (!this.state.data || this.state.loading) {
      return (
        <div className="app">
          <p>Loading...</p>
        </div>
      );
    }

    return (
      <div className="app">
        <div className="item-list-container">
          <h1>To-do items:</h1>
          <ul className="item-lift">
            {this.state.data.todoItems.map(todoItem => (
              <li key={todoItem.id}>{todoItem.text}</li>
            ))}
          </ul>
        </div>
      </div>
    );
  }
}

export default App;
Copy

If everything is set up correctly, we should first see the "Loading..." message when our to-do items are not yet loaded and after that, a list of the following three items: "Learn GraphQL", "Learn React", and "Be awesome".

But there's still plenty of things we haven't dealt with yet! For example, what if our server doesn't respond in time and the request times out? Or what if our GraphQL query is invalid and the API responds with an error? Let's take a look at how we can deal with errors in the next section.

Handling network and GraphQL errors

There are many things that can go wrong when we make network requests to our GraphQL API:

  • Network errors which can occur when our users are experiencing bad network conditions or our servers are down/unreachable for whatever reason
  • GraphQL errors which can happen before or during the execution of a GraphQL operation, i.e. when the query is invalid

Before we implement any error handling, we'll update our initial state and our render method.

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    loading: false,
    error: null,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    //as before
  };

  render() {
    if (this.state.error) {
      return (
        <div className="app">
          <p>{this.state.error.message}</p>
        </div>
      );
    }

    if (!this.state.data || this.state.loading) {
      return (
        <div className="app">
          <p>Loading...</p>
        </div>
      );
    }

    return (
      <div className="app">
        <div className="item-list-container">
          <h1>To-do items:</h1>
          <ul className="item-list">
            {this.state.data.todoItems.map(todoItem => (
              <li key={todoItem.id}>{todoItem.text}</li>
            ))}
          </ul>
        </div>
      </div>
    );
  }
}

export default App;
Copy

Network errors are easy to deal with because the returned Promise by fetch is rejected in that case. We can attach a .catch handler to it and set the error state for our component in there. Also, now that we have an error state, let's remember to set the error object to null in the success case as well as when we initiate new fetch requests!

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    loading: false,
    error: null,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: `query { todoItems { id text completed } }`
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          this.setState({
            loading: false,
            error: null,
            data: responseAsJson.data
          });
        })
        .catch(error => {
          this.setState({
            loading: false,
            error,
            data: null
          });
        });
    });
  };

  render() {
    if (this.state.error) {
      return (
        <div className="app">
          <p>{this.state.error.message}</p>
        </div>
      );
    }

    if (!this.state.data || this.state.loading) {
      return (
        <div className="app">
          <p>Loading...</p>
        </div>
      );
    }

    return (
      //as before
    );
  }
}

export default App;
Copy

We can test that our code is working by shutting down the Prisma server. Head back to the server directory and run the following command:

cd ../server
docker-compose down
Copy

Now, if we refresh our React app, we should see an error message like "Failed to fetch" because our app can't connect to the server anymore. Before we continue, let's not forget to bring our server back up with:

docker-compose up -d
Copy

GraphQL errors do not cause the returned Promise to reject. They end up in the response body as an errors property which, according to the specification, should only be present if it's non-empty. It contains objects with information about what went wrong before or during execution of a GraphQL operation. We can very easily cause our Prisma server to set an error by sending an invalid query. For example, we could willingly introduce a typo into our query and try to select the field texd instead of text:

query {
  todoItems {
    id
    texd
    completed
  }
}
Copy

With such a query, our GraphQL API would respond with the following JSON data:

{
  "data": null,
  "errors": [
    {
      "message": "Cannot query field 'texd' on type 'TodoItem'. Did you mean 'text'? (line 5, column 17):
                texd
                ^",
      "locations": [
        {
          "line": 5,
          "column": 17
        }
      ]
    }
  ]
}

Let's handle these errors in our fetchData method as well. To keep things simple, we're going to check if the errors array exists and if so, extract the first error object:

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    loading: false,
    error: null,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: `query { todoItems { id texd completed } }`
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          if (responseAsJson.errors) {
            this.setState({
              loading: false,
              error: responseAsJson.errors[0],
              data: responseAsJson.data
            });
          } else {
            this.setState({
              loading: false,
              error: null,
              data: responseAsJson.data
            });
          }
        })
        .catch(error => {
          this.setState({
            loading: false,
            error,
            data: null
          });
        });
    });
  };

  render() {
    if (this.state.error) {
      return (
        <div className="app">
          <p>{this.state.error.message}</p>
        </div>
      );
    }

    if (!this.state.data || this.state.loading) {
      return (
        <div className="app">
          <p>Loading...</p>
        </div>
      );
    }

    return (
      <div className="app">
        <div className="item-list-container">
          <h1>To-do items:</h1>
          <ul className="item-list">
            {this.state.data.todoItems.map(todoItem => (
              <li key={todoItem.id}>{todoItem.text}</li>
            ))}
          </ul>
        </div>
      </div>
    );
  }
}

export default App;
Copy

With that logic in place, our app should display the error message for an invalid query. Before you continue, go ahead and change the GraphQL query to something invalid and make sure that the UI is showing you the error!

Writing parameterized queries using fetch

GraphQL queries can also be parameterized. This can be very useful for things like pagination or filtering, where we need the same query multiple times with just a few different variables. In our example, we could change our query to only return the first two to-do items, by using the following query.

query {
  todoItems(first: 2) {
    id
    text
    completed
  }
}
Copy

With such a query we get exactly two items back from our API. This works great but it's not that useful if we can't update the value of the first argument programatically. What if, for example, the user should be able to set how many to-do items should be displayed? In such cases, we can set an additional property in our request object called variables.

First, we need an input element for the number of to-do items to query and a submit button to trigger our fetchData method.

// App.js
import React, { Component } from "react";
import "./App.css";

class App extends Component {
  state = {
    count: "3",
    loading: false,
    error: null,
    data: null
  };

  handleCountChange = e => {
    this.setState({ count: e.target.value });
  };

  handleSubmit = e => {
    e.preventDefault();

    this.fetchData();
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: `
            query($count: Int) {
              todoItems(first: $count) {
                id
                text
                completed
              }
            }
          `,
          variables: {
            count: parseInt(this.state.count)
          }
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          if (responseAsJson.errors) {
            this.setState({
              loading: false,
              error: responseAsJson.errors[0],
              data: responseAsJson.data
            });
          } else {
            this.setState({
              loading: false,
              error: null,
              data: responseAsJson.data
            });
          }
        })
        .catch(error => {
          this.setState({
            loading: false,
            error,
            data: null
          });
        });
    });
  };

  render() {
    return (
      <div className="app">
        <form className="count-form" onSubmit={this.handleSubmit}>
          <input
            type="number"
            value={this.state.count}
            onChange={this.handleCountChange}
          />
          <button type="submit">Submit</button>
        </form>
        <div className="item-list-container">
          <h1>To-do items:</h1>
          {this.state.error && <p>{this.state.error.message}</p>}
          {this.state.loading && <p>Loading...</p>}
          {this.state.data && (
            <ul className="item-list">
              {this.state.data.todoItems.map(todoItem => (
                <li key={todoItem.id}>{todoItem.text}</li>
              ))}
            </ul>
          )}
        </div>
      </div>
    );
  }
}

export default App;
Copy

The most important part here is how we construct our request body object. We updated our query to take a $count variable of type Int and have a new variables object there that contains that same count variable set as the current value of the input element.

Now, we can control exactly how many to-do items our API should respond with! This is great but somehow our App component has grown quite a bit in complexity. Mainly because, in addition to keeping track of loading and error states, it's also responsible for the form state. Let's look into how we can extract at least the data fetching parts into a reusable Query component.

Creating a Query component

Many GraphQL clients for React expose a Query component that accepts a GraphQL query string, optional variables, and a render prop that gets information about the data to load. Create a Query.js file and paste in the following code:

//Query.js
import { Component } from "react";
import PropTypes from "prop-types";

class Query extends Component {
  static propTypes = {
    query: PropTypes.string.isRequired,
    variables: PropTypes.object
  };

  static defaultProps = {
    variables: {}
  };

  state = {
    loading: false,
    error: null,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: this.props.query,
          variables: this.props.variables
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          if (responseAsJson.errors) {
            this.setState({
              loading: false,
              error: responseAsJson.errors[0],
              data: responseAsJson.data
            });
          } else {
            this.setState({
              loading: false,
              error: null,
              data: responseAsJson.data
            });
          }
        })
        .catch(error => {
          this.setState({ loading: false, error, data: null });
        });
    });
  };

  render() {
    return this.props.children({
      refetch: this.fetchData,
      loading: this.state.loading,
      error: this.state.error,
      data: this.state.data
    });
  }
}

export default Query;
Copy

Luckily, most of this code looks very familiar to us! There are only a few differences from our previous implementation in the App component:

  • The GraphQL query string and the variables object are passed to the Query component as props.
  • In addition to loading, error, and data, the children prop gets a refetch function as an argument, to support fetching data more than once.

Let's take a look at how this component can take over the heavy lifting of handling data and simplify our App component.

// App.js
import React, { Component } from "react";
import "./App.css";

import Query from "./Query";

class App extends Component {
  state = { count: "3" };

  handleCountChange = e => {
    this.setState({ count: e.target.value });
  };

  handleSubmit = e => {
    e.preventDefault();

    this.fetchData();
  };

  render() {
    return (
      <Query
        query={`
          query($count: Int) {
            todoItems(first: $count) {
              id
              text
              completed
            }
          }
        `}
        variables={{
          count: parseInt(this.state.count)
        }}
      >
        {({ refetch, loading, error, data }) => (
          <div className="app">
            <form
              className="count-form"
              onSubmit={e => {
                e.preventDefault();

                refetch();
              }}
            >
              <input
                type="number"
                value={this.state.count}
                onChange={this.handleCountChange}
              />
              <button type="submit">Submit</button>
            </form>
            <div className="item-list-container">
              <h1>To-do items:</h1>
              {error && <p>{error.message}</p>}
              {loading && <p>Loading...</p>}
              {data && (
                <ul className="item-list">
                  {data.todoItems.map(todoItem => (
                    <li key={todoItem.id}>{todoItem.text}</li>
                  ))}
                </ul>
              )}
            </div>
          </div>
        )}
      </Query>
    );
  }
}

export default App;
Copy

Now that looks much better!

Mutating data using fetch

A to-do app without the ability to create new to-do items or mark existing ones as completed is not very useful. This is where we're going to need a GraphQL mutation. Before we get to that, let's update our previous example to render completed to-do items differently than uncompleted ones.

// App.js
import React, { Component } from "react";
import "./App.css";

import Query from "./Query";

class App extends Component {
  state = { count: "3" };

  handleCountChange = e => {
    this.setState({ count: e.target.value });
  };

  handleSubmit = e => {
    e.preventDefault();

    this.fetchData();
  };

  render() {
    return (
      <Query
        query={`
          query($count: Int) {
            todoItems(first: $count) {
              id
              text
              completed
            }
          }
        `}
        variables={{
          count: parseInt(this.state.count)
        }}
      >
        {({ refetch, loading, error, data }) => (
          <div className="app">
            <form
              className="count-form"
              onSubmit={e => {
                e.preventDefault();

                refetch();
              }}
            >
              <input
                type="number"
                value={this.state.count}
                onChange={this.handleCountChange}
              />
              <button type="submit">Submit</button>
            </form>
            <div className="item-list-container">
              <h1>To-do items:</h1>
              {error && <p>{error.message}</p>}
              {loading && <p>Loading...</p>}
              {data && (
                <ul className="item-list">
                  {data.todoItems.map(todoItem => (
                    <li
                      key={todoItem.id}
                      className={
                        todoItem.completed ? "item-complete" : "item-incomplete"
                      }
                    >
                      {todoItem.text}
                    </li>
                  ))}
                </ul>
              )}
            </div>
          </div>
        )}
      </Query>
    );
  }
}

export default App;
Copy

Now, if we wanted to toggle the completed state of a to-do item whenever the user clicked on it, we'd need to send a mutation request to our Prisma server and along with that, track the loading state, handle potential errors, and render different things depending on the current state of the mutation request. Since we already did something very similar to the Query component, this time, we'll jump right into creating a Mutation component! Create a Mutation.js file with the following code:

// Mutation.js
import { Component } from "react";
import PropTypes from "prop-types";

class Mutation extends Component {
  static propTypes = {
    mutation: PropTypes.string.isRequired,
    variables: PropTypes.object
  };

  static defaultProps = {
    variables: {}
  };

  state = {
    loading: false,
    error: null,
    data: null
  };

  mutateData = variables => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          query: this.props.mutation,
          variables
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          if (responseAsJson.errors) {
            this.setState({
              loading: false,
              error: responseAsJson.errors[0],
              data: responseAsJson.data
            });
          } else {
            this.setState({
              loading: false,
              error: null,
              data: responseAsJson.data
            });
          }
        })
        .catch(error => {
          this.setState({ loading: false, error, data: null });
        });
    });
  };

  render() {
    return this.props.children({
      mutate: this.mutateData,
      loading: this.state.loading,
      error: this.state.error,
      data: this.state.data
    });
  }
}

export default Mutation;
Copy

As you can see, this looks almost identical to the Query component we wrote earlier! The only difference is that instead of taking a query prop, the Mutation component calls the prop mutation. Also, the variables are not coming from props but as an argument to the mutate function which is exposed in the render prop. Everything else is exactly the same!

To allow to-do items to be "checkable", here is how our App component should look:

// App.js
import React, { Component } from "react";
import "./App.css";

import Query from "./Query";
import Mutation from "./Mutation";

class App extends Component {
  state = { count: "3" };

  handleCountChange = e => {
    this.setState({ count: e.target.value });
  };

  handleSubmit = e => {
    e.preventDefault();

    this.fetchData();
  };

  render() {
    return (
      <Query
        query={`
          query($count: Int) {
            todoItems(first: $count) {
              id
              text
              completed
            }
          }
        `}
        variables={{
          count: parseInt(this.state.count)
        }}
      >
        {({ refetch, loading, error, data }) => (
          <div className="app">
            <form
              className="count-form"
              onSubmit={e => {
                e.preventDefault();

                refetch();
              }}
            >
              <input
                type="number"
                value={this.state.count}
                onChange={this.handleCountChange}
              />
              <button type="submit">Submit</button>
            </form>
            <div className="item-list-container">
              <h1>To-do items:</h1>
              {error && <p>{error.message}</p>}
              {loading && <p>Loading...</p>}
              {data && (
                <ul className="item-list">
                  {data.todoItems.map(todoItem => (
                    <Mutation
                      mutation={`
                        mutation(
                          $where: TodoItemWhereUniqueInput!
                          $data: TodoItemUpdateInput!
                        ) {
                          updateTodoItem(where: $where, data: $data) {
                            id
                            text
                            completed
                          }
                        }
                      `}
                      key={todoItem.id}
                    >
                      {({ mutate, data }) => {
                        const mutationResultExists = Boolean(data);
                        const completed = mutationResultExists
                          ? data.updateTodoItem.completed
                          : todoItem.completed;

                        return (
                          <li
                            className={
                              completed ? "item-complete" : "item-incomplete"
                            }
                            onClick={() =>
                              mutate({
                                where: { id: todoItem.id },
                                data: { completed: !completed }
                              })
                            }
                          >
                            {todoItem.text}
                          </li>
                        );
                      }}
                    </Mutation>
                  ))}
                </ul>
              )}
            </div>
          </div>
        )}
      </Query>
    );
  }
}

export default App;
Copy

The mutation string here looks very similar to the parameterized query example with multiple parameters. We render a Mutation for each to-do item, disregard the loading and error states for the sake of simplicity and render the li elements clickable. Once an element is clicked, we call the mutate function with the variables for the mutation request.

What's interesting here is what we do at line 77. After we receive the mutation response, we now have two data sources for the same to-do item! One from the Query component and one from the Mutation component. What we need to do is somehow merge the data and let the mutation result take precedence over the query result. This is what the mutationResultExists variable is used for. Before determining the completed state of a to-do item, we make sure to check if a more recent version of the to-do item is available as a mutation result. If not, we take the previous state from the query result.

This is an exciting problem because if some other parts of our app would depend on the list of to-do items, we'd need to make sure not to render outdated versions of them!

Now, we should be able to toggle the completed state of each to-do item by clicking on them! If you run into problems, make sure you're handling the mutation data correctly. Take a look at the code from above as a reference.

Enabling authentication

You might have noticed that we haven't had to deal with authentication at all! That's because our Prisma API is currently publicly available. While having no authentication enabled is not so bad for demonstration purposes, in real-life situations you'll want to restrict access to your GraphQL API to only authorized parties. To emulate this situation, we're going to enable authentication for our Prisma service by setting the secret key in our prisma.yml file in the server directory.

endpoint: http://localhost:4466
datamodel: datamodel.prisma
seed:
  import: seed.graphql
secret: your-secret
Copy

If you used the free Demo server in Prisma Cloud instead of Docker, your endpoint will look differently.

After that, the only thing left to do is deploy our changes.

prisma deploy
Copy

If everything worked well, our app should now break and display an error because our API no longer accepts unauthenticated requests. Before we take care of that in the next section, let's generate an access token and save it somewhere. You will need it for the next step.

prisma token
Copy

Handling authentication with fetch

Handling authentication means just adding another HTTP header to the requests that we make. In our case, we're going to set a custom Authorization header in the Query and Mutation components.

// Query.js
import { Component } from "react";
import PropTypes from "prop-types";

class Query extends Component {
  static propTypes = {
    query: PropTypes.string.isRequired,
    variables: PropTypes.object
  };

  static defaultProps = {
    variables: {}
  };

  state = {
    loading: false,
    error: null,
    data: null
  };

  componentDidMount() {
    this.fetchData();
  }

  fetchData = () => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.REACT_APP_PRISMA_TOKEN}`
        },
        body: JSON.stringify({
          query: this.props.query,
          variables: this.props.variables
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          if (responseAsJson.errors) {
            this.setState({
              loading: false,
              error: responseAsJson.errors[0],
              data: responseAsJson.data
            });
          } else {
            this.setState({
              loading: false,
              error: null,
              data: responseAsJson.data
            });
          }
        })
        .catch(error => {
          this.setState({ loading: false, error, data: null });
        });
    });
  };

  render() {
    return this.props.children({
      refetch: this.fetchData,
      loading: this.state.loading,
      error: this.state.error,
      data: this.state.data
    });
  }
}

export default Query;
Copy

Don't forget to do the same thing for the Mutation.js file:

// Mutation.js
import { Component } from "react";
import PropTypes from "prop-types";

class Mutation extends Component {
  static propTypes = {
    mutation: PropTypes.string.isRequired
  };

  state = {
    loading: false,
    error: null,
    data: null
  };

  mutateData = variables => {
    this.setState({ loading: true, error: null }, () => {
      fetch("http://localhost:4466/", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.REACT_APP_PRISMA_TOKEN}`
        },
        body: JSON.stringify({
          query: this.props.mutation,
          variables
        })
      })
        .then(response => {
          return response.json();
        })
        .then(responseAsJson => {
          if (responseAsJson.errors) {
            this.setState({
              loading: false,
              error: responseAsJson.errors[0],
              data: responseAsJson.data
            });
          } else {
            this.setState({
              loading: false,
              error: null,
              data: responseAsJson.data
            });
          }
        })
        .catch(error => {
          this.setState({ loading: false, error, data: null });
        });
    });
  };

  render() {
    return this.props.children({
      mutate: this.mutateData,
      loading: this.state.loading,
      error: this.state.error,
      data: this.state.data
    });
  }
}

export default Mutation;
Copy

Restart the development server and set the environment variable REACT_APP_PRISMA_TOKEN to the newly generated token from the previous section.

REACT_APP_PRISMA_TOKEN=your-access-token npm start
Copy

That's it! Our app should now be working again and display to-do items!

Conclusion

We learned a couple of interesting things while building this simple to-do app:

  • We found that components grew quickly in complexity when tracking state for data and we needed reusable components for ease-of-use, increased readability, as well as to reduced maintenance costs.
  • Error handling is not always straight-forward in GraphQL. We need to handle network and potentially multiple GraphQL errors.
  • Once we have mutations that update the state of previously fetched query results, we need to come up with a solution to merge these results. If we don't, we might run into issues later on when other parts of our app could render potentially outdated versions of our data.
  • Our components are far away from being production-ready. They are untested with certain issues in specific edge-cases. You should see them as learning material only.
  • Finally, we have no support for GraphQL subscriptions! Real-time data with WebSockets is an entirely different topic and comes with its own set of problems.

Now we know what problems GraphQL clients are trying to solve! They come with ready-to-use functions and components baked-in to get started with GraphQL much faster so we can focus on more important things. Components like Query or Mutation have a much richer API with fine-grained control over how responses are cached, how that cache is updated, how errors are handled etc. Users don't need to wait for the same data twice, and developers don't need to worry about multiple states of the same data when the client is dealing with these issues for us.

With that in mind, if we go back to the question "Why do I need a GraphQL client?", we can say that we don't necessarily need them for very simple use-cases, like our example to-do app. But as our app grows, sooner or later we'll likely run into issues where libraries like Relay or Apollo Client can help.