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
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
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
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')
);
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
}
}
}`;
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
}
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>
);
};
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';
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"
}
}
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.
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>
);
};
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.
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([]);
Remember when I defined the second query and there were two filters applied to $name
and $genre
?
queryFilm(filter: $name)
genre(filter: $genre)
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
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"}}
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:
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} });
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);
}
};
Here are handlers for the first two cases.
handleGenreSelect
is used by AutoComplete
as we saw previously:
onChange={ (event, selectedGenre) => handleGenreSelect(event, selectedGenre) }
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 = "";
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);
};
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
}
}
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'
}
]
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>
);
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;
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'
};
Then I place the Genre
and UserInput
component, 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 columns
property 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;
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:
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)