DEV Community

Cover image for Learning GraphQL and React: My First
App
Abu Sakib
Abu Sakib

Posted on • Edited on

Learning GraphQL and React: My First App

I took my first leap into React a couple of months ago as part of the freeCodeCamp web development curriculum that I was following. Since then, I've discovered a wide range of tools and technologies that power the web and software industry. So I came to know about GraphQL, "a query language for APIs and a runtime for fulfilling those queries with your existing data". I was pretty familiar with REST, and after taking a short introduction, I realized how powerful GraphQL is; it eliminates the shortcomings of REST while providing ease of development for programmers.

I also got to read about graphs, databases and how all of these fit together into the picture. I discovered Dgraph, an open-source native GraphQL graph database, the only one of its kind, written entirely from scratch, in Go. So I decided to do a little project that would give me a hands-on experience with all of this.

The idea is pretty simple:

send queries to a GraphQL server based on user input and render the data in the UI.

The app is going to send queries to an existing Dgraph server instance located at https://play.dgraph.io/graphql that holds a ton of information on films via Google's Freebase film data.

Let's start!

Getting started

Getting started is totally hassle-free, thanks to Create React App:

npx create-react-app graphql-react-app
Enter fullscreen mode Exit fullscreen mode

This creates the app in a new directory graphql-react.app, and it takes only two commands to launch it in the browser:

cd graphql-react-app
npm start
Enter fullscreen mode Exit fullscreen mode

This would start the app at http://localhost:3000/.

Meet Apollo

Apollo is a GraphQL client for JavaScript. It works really well with frameworks/libraries like React, Angular etc. Now you might ask why we need a client?

Generally, all of the resources of a GraphQL service are exposed over HTTP via a single endpoint. So yes you could just use the good-old fetch. But it wouldn't be scalable, not unless you implement all the functionalities like caching, UI integration for React or Angular by yourself; and that's overkill. A client like Apollo comes packed with all of these functionalities, and more, so you can just focus on developing your app, without getting distracted by the extra work.

So let's install Apollo:

npm install @apollo/client graphql
Enter fullscreen mode Exit fullscreen mode

This is going to install the following packages:

  • @apollo/client: This is the Apollo client and with this we're set for stuffs like caching, error handling etc.
  • graphql: This package is needed for parsing GraphQL queries.

In the index.js file, I import the following packages and create the client by using the ApolloClient constructor, while passing an object with a uri property whose value is the server, while also setting up the caching mechanism:

import App from './App';
import { 
  ApolloClient, 
  ApolloProvider,
  InMemoryCache 
} from '@apollo/client';

const APOLLO_CLIENT = new ApolloClient({
  uri: "https://play.dgraph.io/graphql",
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          queryFilm: {
            merge(_ignored, incoming) {
              return incoming;
            },
          },
        },
      },
    },
  })
});

ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={APOLLO_CLIENT}>
      <App />
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

The cache could be set up with just cache: new InMemoryCache(), but in this case, I define a custom merge function to silence some warnings in the console. Basically, what this does, is that this function is called by the cache every time there's an incoming value from the server which is going to overwrite the cache with the new information. The incoming value returned by this function is written over the existing data in the cache; by explicitly telling the cache to do so completely replaces the cache with new information while also silencing the warnings. This part would become clearer when I define the queries.

Now I need to connect Apollo Client with React, that is done via the ApolloProvider component. The app would be wrapped with this component that exposes the client to the context of the ApolloClient instance so that it can be used throughout the component tree, so even though we are going to do all of our work in App.js, the APOLLO_CLIENT instance is going to be available there.

I import the App component, and wrap it with ApolloProvider, passing the client instance as a prop.

Defining our queries

Now I define the queries for the app. Here I need to use gql which I've already imported above. This is a utility provided by Apollo that parses GraphQL queries into what's called an "Abstract Syntax Tree (AST)". AST isn't something totally unique in GraphQL; it's a structure used by compilers such as C/C++ compilers to parse the code we humans write into "tree structures" that can be traversed. So, using gql, we're sending a tree-representation of our query to the server which the machine is able to understand, it then traverses the tree executing the request against the schema defined in the server.

const QUERY_FILM_GENRES = gql`{
  queryGenre @cascade {
    name
  }
}`;

const QUERY_FIND_FILMS = gql`
  query($name: FilmFilter, $genre: GenreFilter) {
    queryFilm(filter: $name) @cascade {
      name
      genre(filter: $genre) {
        name
      }
      directed_by {
        name
      }
    }
}`;
Enter fullscreen mode Exit fullscreen mode

There are two queries here. The first query is going to request the names of all the genres that are in the server and populate a drop-down menu. The user can select a genre, and then type in a movie name or a phrase or just a word in an input field that might belong to that particular genre; the second query is going to take all this information and make another request. The response data would hold the film name(s) and director(s) that would be shown in a table.

The second query holds two query variables: $name and $genre, that the user would supply values with, for the film name and its genre. The user might not select a genre, or no name either, in that case the value is going to be null.

FilmFilter and GenreFilter are both types that are defined in the schema of the server. In a GraphQL server, schemas define what type of information can be queried. The type system defines the types of data there are, in object-like structures. In this case, there's a FilmFilter object type that could hold the following fields:

type FilmFilter {
    id
    initial_release_date
    name
}
Enter fullscreen mode Exit fullscreen mode

Just like this, our GenreFilter has id and name fields. In both cases, I only care about the name of the genre and film so only those are in the queries.

Another thing to notice is @cascade. It's a directive that gives us only those genres that have name field inside them in the first query, and likewise films who have name, genre and directed_by fields in the second query. The directive flows down from where it's defined; so for the first query, each genre must have a name, and for the second one, each film must have a name and both genre and directed_by must also have a name inside them. If any of these fields are valued null, they won't be returned. @cascade is helpful in situations where some sort of filter is applied, in this case, I'm filtering by name and genre: filter: $name and filter: $genre.

Components

The app is going to have three additional components besides the main App component. The first is going to be for the drop-down menu. The third component is simply for decoration purposes that we'll see later.

function Genre({handleGenreSelect}) {

  let { loading, error, data } = useQuery(QUERY_FILM_GENRES);

  if (loading) {
      return <CircularProgress />
  } else if (error) {
      return (
        <Alert severity="error">
          <AlertTitle>Error</AlertTitle>
          Sorry, something might not be working at the moment!
        </Alert>
      )
  }

  var filmGenres = [];
  data.queryGenre.forEach(
    (genreObject) => filmGenres.push(genreObject.name));

  return (
    <Autocomplete 
      id="film-box" 
      options={ filmGenres } 
      onChange={ (event, selectedGenre) => handleGenreSelect(event, selectedGenre) }
      style={{ width: 300 }} 
      getOptionLabel={(option) => option}
      renderInput={
        (params) => <TextField {...params} label="Select genre" variant="outlined" />
      }>
    </Autocomplete>
  );

};
Enter fullscreen mode Exit fullscreen mode

The Genre component receives a prop called handleGenreSelect from the App component; this is a handler function that's going to catch the genre value the user selects since I need it to use in the query.

This component is responsible for the drop-down menu.

I imported useQuery as shown in the previous section. It's a React hook that is used to run a query in an Apollo-React app. To do this, I pass our query string, QUERY_FILM_GENRES to it as shown above. The hook call returns an object that contains loading, error anddata properties. The loading property gives the loading state, i.e. when the data hasn't arrived yet. Any errors that might occur in the process can be caught from the error property. And the result of the query is contained in the data property after it arrives.

This might be a good place to say that I'm going to be using Material-UI as a React UI framework. Below are all its component imports in the App.js file:

import Container  from "@material-ui/core/Container";
import TextField from '@material-ui/core/TextField';
import {
  Autocomplete,
  Alert,
  AlertTitle
} from '@material-ui/lab';
import Input from '@material-ui/core/Input';
import Button from '@material-ui/core/Button';
import MaterialTable from 'material-table';
import CircularProgress from '@material-ui/core/CircularProgress';
Enter fullscreen mode Exit fullscreen mode

As long as the loading state persists, I show a progress bar to the UI using Material-UI's CircularProgress component. If there's an error, I show an "error" message using the Alert component.

If everything goes well, data would contain all the genre names from the server, for example:

{
  "data": {
    "queryGenre": [
      {
        "name": "Crime Thriller"
      },
      {
        "name": "Educational film"
      },
      {
        "name": "Chinese Movies"
      },
      {
        "name": "Experimental film"
      }
}   
Enter fullscreen mode Exit fullscreen mode

This is one of the neat things about GraphQL: we get exactly what we want from the server. If you compare the query and the JSON response here, you'd realize how simple it is to request something and get exactly that in return, nothing more nothing less.

I then use that array to populate the Autocomplete component provided by Material-UI. This component has built-in suggestion feature so when I start typing in, I get suggestions. This particular spin of Autocomplete is called combo box.

Combo box for selecting genres

The second component is for handling the user input and submit functions.

function UserInput({handleInputChange, handleSubmit}) {

  return (
    <form>
      <Input placeholder="Film name" onChange={ handleInputChange }>
      </Input>
      <Button type="submit" variant="contained" onClick={ handleSubmit } color="primary" style={{ marginLeft: 20 }}>
        Submit
      </Button>
    </form>
  );

};
Enter fullscreen mode Exit fullscreen mode

It takes two props from the App component, both are handler functions just like the previous one: handleInputChange catches what the user types in in the input field of theInput component, while handleSubmit is triggered as soon as the "submit" Button is pressed. The query is then sent to the server to get the desired data.

Rendering of the UserInput component

And now inside the App component, I define the necessary states using useState hook:

const [ nameFilter, setNameFilter ] = useState({name: {alloftext: "Summer"}});
const [ genreFilter, setGenreFilter ] = useState(null);
const [ dataForRender, setDataForRender ] = useState([]);
Enter fullscreen mode Exit fullscreen mode

Remember when I defined the second query and there were two filters applied to $name and $genre?

queryFilm(filter: $name)
genre(filter: $genre)
Enter fullscreen mode Exit fullscreen mode

Since the user would type in a phrase or word to search for a film, I have to take that into consideration and hence I use a filter. So for example, if the user types in the word "Summer" and selects nothing as the genre, it would look like this:

"name": {"name": {"alloftext": "Summer"}},
"genre": null
Enter fullscreen mode Exit fullscreen mode

So "name": {"name": {"alloftext": "Summer"}} and null would be the values for our two variables $name and $genre respectively.

What if the user selects a genre from the drop-down menu, say for example, "Animation"? Then we would have:

"genre": {"name":{"eq": "Animation"}}
Enter fullscreen mode Exit fullscreen mode

Notice that they are very much the same.

You can have a clearer vision of this if you use a GraphQL IDE like GraphQL Playground or GraphiQL and use the query in the query field and supply the relevant variables. See below for a snapshot:

A screenshot of GraphQL Playground

Keeping these in mind, I define the first state containing value for $name variable as {name: {alloftext: "Summer"}} (notice that using quotation around name and is not necessary here). $genre is set to null.

The third hook is for the final data that I need to show; using setRenderData would cause that component to re-render as soon as the data arrives and is ready to be shown to the user.

Using useQuery, I run the second query:

const { loading, error, data, refetch } = useQuery(QUERY_FIND_FILMS, 
    { variables: {name: nameFilter, genre: genreFilter} });
Enter fullscreen mode Exit fullscreen mode

This time I'm also passing the variables as a second argument, which is an object, to the hook call.

Now let's look at the handler functions defined in the App component that are passed as props to other components as we saw previously.

Handlers

I need three handlers for my app: for catching what genre the user selects, what the user types in the input field and the click on the submit button:

const handleGenreSelect = (event, selectedGenre) => {
    if(selectedGenre) {
      setGenreFilter({name: { eq: selectedGenre }});
    } else {
      setGenreFilter(null);
    }
};

const handleInputChange = (event) => {
    if (event.target.value) {
      setNameFilter({name: {alloftext: event.target.value}});
    } else {
      setNameFilter(null);
    }
};
Enter fullscreen mode Exit fullscreen mode

Here are handlers for the first two cases.

handleGenreSelect is used by AutoCompleteas we saw previously:

onChange={ (event, selectedGenre) => handleGenreSelect(event, selectedGenre) }
Enter fullscreen mode Exit fullscreen mode

So for an onChange event on the AutoComplete component, I define a function that calls handleGenreSelect with that specific event and selectedGenre as the value of what the user selected. If the user doesn't select anything, selectedGenre would be null, so I set the state accordingly; if the user selects a genre, I set the state equal to that value using setGenreFilter.

handleInputChange is for the input field to catch whatever the user typed through event.target.value and set the state using setNameFilter. Just like handleGenreSelect, here I also check for null.

Before looking at the third handler, let's define a couple of variables:

var filmsAndDirectors;
var arrayOfFilmNames = [];
var arrayOfFilmDirectors = [];
var multipleDirectors = "";
Enter fullscreen mode Exit fullscreen mode

Now here's our final and most important handler:

const handleSubmit = async (event) => {
  event.preventDefault();
  const { data: newData } = await refetch({ 
    variables: {name: nameFilter, genre: genreFilter} 
  });

  // get film names
  newData.queryFilm.forEach((filmObject) => arrayOfFilmNames.push(filmObject.name));

  // get corresponding directors
  newData.queryFilm.forEach((filmObject) => {
    // for multiple directors show in comma-separated list
    if (filmObject.directed_by.length > 1) {
      filmObject.directed_by.forEach((dirObject) => {
        multipleDirectors += dirObject.name + ", ";
      })
      arrayOfFilmDirectors.push(
        multipleDirectors.trim().substr(0, multipleDirectors.length - 2));
      multipleDirectors = "";
    } else {
      filmObject.directed_by.forEach((dirObject) => arrayOfFilmDirectors.push(dirObject.name))
    }
  });

  // create array of objects of film and their directors
  filmsAndDirectors = [];
  var tempObj = {};
  arrayOfFilmNames.forEach((key, i) => {
    tempObj.name = key;
    tempObj.director = arrayOfFilmDirectors[i];
    filmsAndDirectors.push(tempObj);
    tempObj = {};
  });
  setDataForRender(filmsAndDirectors);
};
Enter fullscreen mode Exit fullscreen mode

As soon as the "submit" button is clicked, this handler is triggered. Inside, I call another function called refetch, that was extracted earlier as part of the useQuery call. Refetching is required in these types of situations when we need to "update" our query results based on user actions.

refetch returns a Promise, which when resolved successfully, would indicate that the desired data has arrived. That's why I use an async function here and an await inside it to wait for refetch to finish its task. The refetch function takes the variables as parameters that contain all the user input: genre and the film name/phrase/word.

After the promise resolves successfully, the data is contained in newData. For example, if the user selected "Animation" as genre and typed in "Fantastic", the response gives all films in that genre that contains that word and their directors:

  "data": {
    "queryFilm": [
      {
        "name": "Fantastic Planet",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "René Laloux"
          }
        ],
        "initial_release_date": "1973-05-01T00:00:00Z"
      },
      {
        "name": "The Cameraman's Revenge & Other Fantastic Tales",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "Ladislas Starewitch"
          }
        ],
        "initial_release_date": "1958-01-01T00:00:00Z"
      },
      {
        "name": "Noel's Fantastic Trip",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "Tsuneo Maeda"
          }
        ],
        "initial_release_date": "1983-04-29T00:00:00Z"
      },
      {
        "name": "Fantastic Mr. Fox",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "Wes Anderson"
          }
        ],
        "initial_release_date": "2009-10-14T00:00:00Z"
      },
      {
        "name": "Fantastic Animation Festival",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "Christopher Padilla"
          },
          {
            "name": "Dean A. Berko"
          }
        ],
        "initial_release_date": "1977-05-27T00:00:00Z"
      },
      {
        "name": "The Fantastic Flying Books of Mr. Morris Lessmore",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "William Joyce"
          },
          {
            "name": "Brandon Oldenburg"
          }
        ],
        "initial_release_date": "2011-01-30T00:00:00Z"
      },
      {
        "name": "Daffy Duck's Fantastic Island",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "Friz Freleng"
          },
          {
            "name": "Chuck Jones"
          },
          {
            "name": "Phil Monroe"
          }
        ],
        "initial_release_date": "1983-01-01T00:00:00Z"
      },
      {
        "name": "Piper Penguin and His Fantastic Flying Machines",
        "genre": [
          {
            "name": "Animation"
          }
        ],
        "directed_by": [
          {
            "name": "Michael Schelp"
          }
        ],
        "initial_release_date": "2008-01-01T00:00:00Z"
      }
    ]
  },
  "extensions": {
    "touched_uids": 470
  }
}
Enter fullscreen mode Exit fullscreen mode

From this data, I extract all the film names and their corresponding directors in two arrays called arrayOfFilmNames and arrayOfFilmDirectors. Then an array of objects is constructed that would hold all this information in filmsAndDirectors. In this case, filmsAndDirectors would be:

[
  { name: 'Fantastic Planet', director: 'René Laloux' },
  {
    name: "The Cameraman's Revenge & Other Fantastic Tales",
    director: 'Ladislas Starewitch'
  },
  { name: "Noel's Fantastic Trip", director: 'Tsuneo Maeda' },
  { name: 'Fantastic Mr. Fox', director: 'Wes Anderson' },
  {
    name: 'Fantastic Animation Festival',
    director: 'Christopher Padilla, Dean A. Berko,'
  },
  {
    name: 'The Fantastic Flying Books of Mr. Morris Lessmore',
    director: 'William Joyce, Brandon Oldenburg,'
  },
  {
    name: "Daffy Duck's Fantastic Island",
    director: 'Friz Freleng, Chuck Jones, Phil Monroe,'
  },
  {
    name: 'Piper Penguin and His Fantastic Flying Machines',
    director: 'Michael Schelp'
  }
]
Enter fullscreen mode Exit fullscreen mode

Using setRenderData, which is initially assigned an empty array, I set the state and assign it the value of filmsAndDirectors. So if all goes well, hitting the submit button would set the state with new information and the component would re-render.

You might've noticed the extensions field in the response; it contains some metadata for the request; in this case touched_uids indicate how many nodes were touched to get the data (remember AST?).

Final Result

Let's look at the App component's return function where I return all the components defined so far:

return (

  <div>
    <Header />
    <br></br>
    <Container maxWidth="xs" style={ getContainerStyle }>

      <Genre handleGenreSelect={handleGenreSelect} />
      <br></br>

      <h3 style={{ marginTop: 50 }}>
        Enter a film name or phrase:
      </h3>

      <UserInput handleInputChange={handleInputChange} handleSubmit={handleSubmit} />

    </Container>
    <MaterialTable 
        title=""
        columns={[
          { title: 'Name', field: 'name', align: 'center', headerStyle: {
            backgroundColor: '#A5B2FC'
          } },
          { title: 'Director', field: 'director', align: 'center', headerStyle: {
            backgroundColor: '#A5B2FC'
          } }
        ]}
        data={
          dataForRender
        }
        options={{
          search: true
        }}
        style={{ margin: '5rem' }}>
    </MaterialTable>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Header is simply a header bar using Material-UI's Appbar as follows:

import React from 'react';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';

function Header() {
    return (
        <AppBar position="static">
          <Toolbar>
            <h2>Film Information</h2>
          </Toolbar>
        </AppBar>
    )
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

Container is Material-UI's layout component that centers all its children horizontally. The maxWidth property tells it to grow according to the size of the screen; here I assign it the value of xs which means "extra small" screens. The container is styled using the getContainerStyle object:

const getContainerStyle = {
  marginTop: '5rem'
};
Enter fullscreen mode Exit fullscreen mode

Then I place the Genre and UserInputcomponent, passing relevant handlers as props.

Next is MaterialTable, the table where the film names and corresponding directors would be shown. The reason I created an object of films and their directors is because this component takes in an array of objects as its data property, that is going to be shown in table columns and rows.

Details about the columns are passed into the columnsproperty as an array of objects.

I create two columns, one for the film names, with the title Name, and the other for their directors, with the title Director. The field property corresponds to the key names in the array of objects that was created, filmsAndDirectors, which was used to set the state data; they must be the same.

Columns are centered using the align property, as well as custom styling for the column header by assigning an object to the headerStyle property.

data takes in the array of objects from the state, dataForRender, which is equal to filmsAndDirectors. Through the options property, I set search option as true so that the user can search among the table data. Then, some custom styling is applied using the style property.

Lastly, I export App to be used in index.js:

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's start the app with npm start from the root of the app directory, select a genre "Animation", type in "fantastic" as our search term, and hit the submit button:

A GIF of the application

It works!

This simple app shows the core tools to work with for a scenario such as this where we need to build a web app that communicates with a GraphQL server. Using a client like Apollo and having basic understanding of GraphQL, a lot of work becomes easier. I learned a lot about graphs, GraphQL, GraphQL servers, React and much more.

Hopefully, as I attempt to build more stuff, I'd gain a tighter grasp of React and GraphQL.

Top comments (0)