How to Handle Asynchronous Data with React, GraphQL, ApolloClient, AWS AppSync, and AWS Lambda

Overview

In this tutorial, I will show you some tips and tricks on how to work with asynchronous data with modern tooling by demonstrating some example functions. We will be using React, GraphQl, Apollo Client, AWS AppSync, and AWS Lambda. After reading this tutorial, you will be able to read data while doing a basic implementation of search, filter, sort, pagination, and lazy loading.

This project will show you how to process data requests with AWS Lambda. I chose this tool because I find it to be the easiest way to handle data requests. However, it can be adapted to read other data sources if you are comfortable working with AppSync resolver mapping templates written in Apache VTL.

A basic-mid range of knowledge about React, AWS, and GraphQL is expected, but I will link to articles to explain things in more detail along the way to help out if you are not as familiar with these things.


Project initialization

React app

Initialize a new project with create-react-app [1]. Install dependencies by running the following in your terminal from your new project's folder:

1yarn add @apollo/client graphql

Next, in App.js, strip out boilerplate code and import the hooks we will be using, including React and Apollo hooks. The new file should looks something like this:

1// App.js
2import React, { useEffect, useState } from "react";
3import { useQuery, gql } from "@apollo/client";
4
5function App() {
6return (
7  <div>
8    <div>testing 1</div>
9  </div>
10);
11}
12
13export default App;

Now, in index.js, let's import ApolloClient, leaving the uri field blank for now as we will come back to it and plug in our GraphQL server's url. Also, let's wrap our App component with ApolloProvider. We will come back to this file in our next AppSync section to fill in the client uri and api key. For production I recommend storing your url and api key in environment variables. However, for this tutorial I will be hardcoding them. The new file should look something like this:

1// index.js
2import React from "react";
3import ReactDOM from "react-dom";
4import "./index.css";
5import App from "./App";
6import reportWebVitals from "./reportWebVitals";
7import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
8
9const client = new ApolloClient({
10uri: "<YourAppSyncApiUrlHere>",
11cache: new InMemoryCache(),
12headers: {
13  "X-Api-Key": "<YourApiKeyHere>",
14},
15});
16
17ReactDOM.render(
18<React.StrictMode>
19  <ApolloProvider client={client}>
20    <App />
21  </ApolloProvider>
22</React.StrictMode>,
23document.getElementById("root")
24);
25
26// If you want to start measuring performance in your app, pass a function
27// to log results (for example: reportWebVitals(console.log))
28// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
29reportWebVitals();

Lambda

Create a new lambda function [2] with a name relevant to your use case, and make sure the runtime is Node.js. The current version as of writing this article is Node.js 14.x. If you already have a role with basic lambda and cloudwatch permissions, use that. Otherwise, select 'create a new role with basic Lambda permissions'.

If at some point you get an error saying something about CloudWatch permissions, or no logs show up in your CloudWatch for this lambda, follow these directions. Open your lambda and select 'Configuration' and then 'Permissions'. Under Execution Role, click on the role name to open it in a new tab. Click 'Attach Policies' and search for 'cloudwatch'. Check off 'CloudWatchFullAccess' and then 'Attach Policiy'. Go back to your lambda and click 'Code' to begin working on the code.

Copy your 'Function ARN' for the upcoming section on AppSync.

HTTP client

If you have an HTTP request lambda layer, add that. If you don't or don't know what that is, create an empty project folder on your desktop. Add an index.js file to the folder and copy/paste the existing code in your lambda from your AWS console. Navigate to this project folder in your terminal with the 'cd' command and run 'npm init', and then 'npm i elasticsearch axios'. Delete the package-lock.json and package.json files. Highlight the contents of the folder (index.js and node_modules folder), right click and select compress. Back in your AWS terminal for your lambda, select 'Upload from' and then '.zip file' and upload the zip file we just created. If there are any residual files/folders other than index.js and node_modules folder, go ahead and delete those from the AWS console. You should now have a lambda with only an index.js file and a node_modules folder.

AppSync

In your AWS console, initialize a new AppSync api [3]. Select the 'Build from scratch' option, and then 'start'. Go to 'Data sources', and then press 'Create data source'. Give the new data source a relevant name, and select 'AWS Lambda function' as the data source type. Select the region which you created your lambda in, and then paste in your 'Function ARN' from the last section. Select 'New role', and then press 'Create'.

Go to 'Settings' on the left-side panel for your AppSync api. copy the 'API URL' under 'API Details'. Go back to your client-side code to index.js and replace with your API URL. Also, copy your 'API KEY' and replace .


Core Funcitonality

Reading data with GraphQL

In this section we will write our first query [4]. Back in our React code, navigate to App.js. Write a simple query and plug in the parameters needed to fetch the data, and type out your definitions for return values. For this tutorial I will be using a test api that returns mock users, and we will be fetching total users with a couple different parameters. The new file should have syntax that looks like this:

1// App.js
2import React, { useEffect, useState } from "react";
3import { useQuery, gql } from "@apollo/client";
4
5const GetTotalUsers = gql`
6query(
7  $nameLength: Int
8  $nameLastLetter: String
9  $alphabetize: Boolean
10  $page: Int!
11) {
12  GetTotalUsers(
13    nameLength: $nameLength
14    nameLastLetter: $nameLastLetter
15    alphabetize: $alphabetize
16    page: $page
17  ) {
18    Users {
19      id
20      email
21      first_name
22      last_name
23      avatar
24    }
25  }
26}
27`;
28
29function App() {
30return (
31  <div>
32    <div>testing 1</div>
33  </div>
34);
35}
36
37export default App;

Next, leverage the useQuery hook to invoke the query we just wrote. Then use the useEffect and useState hooks to allow React to use your data. One gotcha I've found at the time of writing this article is the Apollo hooks will cause bugs/unwanted behavior if you don't import 'core-js/features/promise' at the top of your file, so we will do that as well. Your new file should look something like this:

1// App.js
2import "core-js/features/promise";
3import React, { useEffect, useState } from "react";
4import { useQuery, gql } from "@apollo/client";
5
6const GetTotalUsers = gql`
7query(
8  $nameLength: Int
9  $nameLastLetter: String
10  $alphabetize: Boolean
11  $page: Int!
12) {
13  GetTotalUsers(
14    nameLength: $nameLength
15    nameLastLetter: $nameLastLetter
16    alphabetize: $alphabetize
17    page: $page
18  ) {
19    Users {
20      id
21      email
22      first_name
23      last_name
24      avatar
25    }
26  }
27}
28`;
29
30function App() {
31const [totalUsers, setTotalUsers] = useState([]);
32console.log("totalUsers: ", totalUsers);
33
34const totalUsersData = useQuery(GetTotalUsers, {
35  variables: {
36    nameLength: 6,
37    nameLastLetter: "l",
38    alphabetize: true,
39    page: 1,
40  },
41});
42console.log("totalUsersData: ", totalUsersData);
43
44useEffect(() => {
45  if (
46    !totalUsersData.loading &&
47    totalUsersData &&
48    totalUsersData.data &&
49    totalUsersData.data.GetTotalUsers &&
50    totalUsersData.data.GetTotalUsers.Users &&
51    totalUsersData.data.GetTotalUsers.Users.length
52  ) {
53    setTotalUsers(totalUsersData.data.GetTotalUsers.Users);
54  }
55}, [totalUsersData]);
56
57return (
58  <div>
59    <div>Total Users</div>
60    <div style={{ marginTop: 10 }}>===============</div>
61    <div style={{ marginTop: 10 }}>Count: {totalUsers.length}</div>
62    {totalUsersData.loading && (
63      <div>
64        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
65        <div>Loading...</div>
66      </div>
67    )}
68    {!totalUsersData.loading && !totalUsersData.length && (
69      <div>
70        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
71        <div>No results returned for this request</div>
72      </div>
73    )}
74    {!totalUsersData.loading && !totalUsersData.error && !totalUsers.length && (
75      <div>
76        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
77        <div>No results returned for this request</div>
78      </div>
79    )}
80    {totalUsersData.error && totalUsersData.error.message && (
81      <div>
82        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
83        <div>Error: {totalUsersData.error.message}</div>
84      </div>
85    )}
86    {totalUsers.map((el) => {
87      return (
88        <div key={el.id}>
89          <div style={{ marginTop: 10, marginBottom: 10 }}>
90            ===============
91          </div>
92          <div>ID: {el.id}</div>
93          <div>Email: {el.email}</div>
94          <div>First Name: {el.first_name}</div>
95          <div>Last Name: {el.last_name}</div>
96        </div>
97      );
98    })}
99  </div>
100);
101}
102
103export default App;

Now, this code won't be functional until we complete our AppSync/Lambda setup, so let's get to it.

AppSync continued

Navigate to your AppSync api in the AWS console. Select 'Schema', and begin adding in the necessary type definitions [5] to link your data with a data source (we are using Lambda). Your schema should now look something like (comments are removed after saving):

1type Query {
2GetTotalUsers(
3	nameLength: Int,
4	nameLastLetter: String,
5	alphabetize: Boolean,
6	page: Int!
7): UserResults
8}
9
10# type Mutation {
11# }
12
13# type Subscription {
14# }
15
16type User {
17id: String
18email: String
19first_name: String
20last_name: String
21avatar: String
22}
23
24type UserResults {
25Users: [User]
26}
27
28schema {
29query: Query
30 # mutation: Mutation
31 # subscription: Subscription
32}

Now, to the right of the schema you should see a 'Resolvers' menu. Locate the Query we just initialized and select 'Attach'. For the 'Data source name', select the lambda we added as a data source in the previous section. Under 'Configure mapping templates', turn on 'Enable request mapping template' and 'Enable response mapping template'. Leave the default mapping templates as they are and make no changes to them. This will allow all data to pass through to the lambda's event prop, and it will allow all data returned from the lambda to pass through back to our React app where we invoked the query in our client-side code. Press 'Save Resolver' and we are done with AppSync.

Lambda continued

Navigate to your lambda in the AWS console. Import axios at the top of index.js by using the require method. Send a get request to your api's url, parse the data, and use context.succeed() to return the data from the lambda. Add some error handling and console.log your data each step of the way to make sure things work. Click the dropdown for the "Test" button. Configure a test event with default parameters. Click the "Test" button to run your lambda and then navigate to the "Moniter" tab and click "View logs in CloudWatch". Select the latest log and make sure the data is appearing as expected. Keep in mind, it takes about 5 seconds or so for a log to show up in CloudWatch, so you may need to press the refresh icon a few times.

Your Lambda function should now look something like this:

1// index.js for lambda function
2const axios = require("axios");
3
4exports.handler = async (event, context) => {
5try {
6  console.log("event: ", event);
7
8  const url = `https://reqres.in/api/users?page=2`;
9
10  const getTotalUsersResult = await axios.get(url);
11
12  if (
13    getTotalUsersResult &&
14    getTotalUsersResult.data &&
15    getTotalUsersResult.data.data
16  ) {
17    console.log("result: ", getTotalUsersResult.data);
18    console.log("data: ", getTotalUsersResult.data.data);
19    context.succeed({ Users: getTotalUsersResult.data.data });
20  } else {
21    console.log(
22      "something went wrong with getTotalUsersResult: ",
23      getTotalUsersResult
24    );
25  }
26} catch (error) {
27  console.log("ERROR: ", error);
28  context.fail(error);
29}
30};

Invoking the GQL query

General

We now need to follow the data from the client invocation to the lambda and back to the client. So, run your React app [1] and observe the console.logs. When the app first loads, it will run useQuery, which will update itself with loading status, errors, data, and more. You can observe this in our log for 'totalUsersData'. Take a look at what data is returned and updated in this variable as the data request processes. Also, have a look at your lambda's CloudWatch logs and observe what is coming in to the event prop. You should see "nameLength", "nameLastLetter", and "page". In our useEffect, we are setting it to watch for changes to the 'totalUsersData' variable. If loading is false and we have data, we update state. So, you can now see our data in our console.log for 'totalUsers' and work with it in our React app.

Search/sort/filter

A basic way of adding search, sort, and filter functionality to your api call is to utilize the variables passed with your useQuery hook, defined in your gql query and your AppSync schema. Your AppSync schema should always match your gql queries in your React app. We will be using array methods to manipulate search results. However, a more powerful way to implement search would be to have your api (the 3rd party api, not your AppSync api) do the filtering instead of using array methods after the data is returned. This will help out with utlizing search/sort/pagination/filtering in conjunction with each other. However, for demo purposes and keeping this article short we will do simple array methods.

The variables you pass in your useQuery in App.js are going to be usable in your Lambda through the event prop. You can see what the event prop looks like by doing a console.log on the event prop and checking your CloudWatch logs. Then you can use them to apply logic on your results. Your new lambda function should look like this:

1// index.js for lambda function
2const axios = require("axios");
3
4exports.handler = async (event, context) => {
5try {
6  console.log("event: ", event);
7
8  const url = `https://reqres.in/api/users?page=2`;
9
10  const getTotalUsersResult = await axios.get(url);
11
12  if (
13    getTotalUsersResult &&
14    getTotalUsersResult.data &&
15    getTotalUsersResult.data.data
16  ) {
17    console.log("result: ", getTotalUsersResult.data);
18
19    let Users = getTotalUsersResult.data.data;
20
21    console.log("Users 1: ", Users);
22
23    if (event.nameLength) {
24      Users = getTotalUsersResult.data.data.filter(
25        (el) => el.first_name.length === event.nameLength
26      );
27    }
28
29    console.log("Users 2: ", Users);
30
31    if (event.nameLastLetter) {
32      Users = getTotalUsersResult.data.data.filter(
33        (el) =>
34          el.first_name[el.first_name.length - 1] === event.nameLastLetter
35      );
36    }
37
38    console.log("Users 3: ", Users);
39
40    if (event.alphabetize) {
41      Users.sort((a, b) =>
42        a.first_name > b.first_name ? 1 : b.first_name > a.first_name ? -1 : 0
43      );
44    }
45
46    console.log("Users 4: ", Users);
47
48    context.succeed({ Users });
49  } else {
50    console.log(
51      "something went wrong with getTotalUsersResult: ",
52      getTotalUsersResult
53    );
54  }
55} catch (error) {
56  console.log("ERROR: ", error);
57  context.fail(error);
58}
59};

Now, you can see the power of required and non-required type definitions in your gql query and AppSync schema by commenting out each parameter in your useQuery hook in App.js, one by one. By commenting out any combination of nameLength, nameLastLetter, or alphabetize, you can see the query results adjust accordingly. However, if we comment out the page variable then we get an error because the ! denotes it is a required parameter. Without this parameter included in the variables object we get an error. We also get an error if any of our parameters do not match the type of data we specified, either 'Int', 'String', or 'Boolean'. This is to help your app become rock-solid and to locate bugs.

Pagination/lazy loading

We can add pagination and lazy loading to our app by leveraging, again, the variables passed with your useQuery hook. These variables are defined in your gql query and your AppSync schema, and pass through to the event prop in your lambda. So let's add a dynamic page value to our api call and add some logic to adjust our totalUsers array accordingly. Your new Lambda function and App.js file should look like this:

1// index.js for lambda function
2const axios = require("axios");
3
4exports.handler = async (event, context) => {
5try {
6  console.log("event: ", event);
7
8  const url = `https://reqres.in/api/users?page=${event.page}`;
9
10  const getTotalUsersResult = await axios.get(url);
11
12  if (
13    getTotalUsersResult &&
14    getTotalUsersResult.data &&
15    getTotalUsersResult.data.data
16  ) {
17    console.log("result: ", getTotalUsersResult.data);
18
19    let Users = getTotalUsersResult.data.data;
20
21    console.log("Users 1: ", Users);
22
23    if (event.nameLength) {
24      Users = getTotalUsersResult.data.data.filter(
25        (el) => el.first_name.length === event.nameLength
26      );
27    }
28
29    console.log("Users 2: ", Users);
30
31    if (event.nameLastLetter) {
32      Users = getTotalUsersResult.data.data.filter(
33        (el) =>
34          el.first_name[el.first_name.length - 1] === event.nameLastLetter
35      );
36    }
37
38    console.log("Users 3: ", Users);
39
40    if (event.alphabetize) {
41      Users.sort((a, b) =>
42        a.first_name > b.first_name ? 1 : b.first_name > a.first_name ? -1 : 0
43      );
44    }
45
46    console.log("Users 4: ", Users);
47
48    context.succeed({ Users });
49  } else {
50    console.log(
51      "something went wrong with getTotalUsersResult: ",
52      getTotalUsersResult
53    );
54  }
55} catch (error) {
56  console.log("ERROR: ", error);
57  context.fail(error);
58}
59};
1// App.js
2import "core-js/features/promise";
3import React, { useEffect, useState } from "react";
4import { useQuery, gql } from "@apollo/client";
5
6const GetTotalUsers = gql`
7query(
8  $nameLength: Int
9  $nameLastLetter: String
10  $alphabetize: Boolean
11  $page: Int!
12) {
13  GetTotalUsers(
14    nameLength: $nameLength
15    nameLastLetter: $nameLastLetter
16    alphabetize: $alphabetize
17    page: $page
18  ) {
19    Users {
20      id
21      email
22      first_name
23      last_name
24      avatar
25    }
26  }
27}
28`;
29
30// if true, add pagination
31// if false, add lazy loading
32const isPaginated = true;
33
34function App() {
35const [currentPage, setCurrentPage] = useState(1);
36const [totalUsers, setTotalUsers] = useState([]);
37console.log("totalUsers: ", totalUsers);
38
39const totalUsersData = useQuery(GetTotalUsers, {
40  variables: {
41    // nameLength: 6,
42    // nameLastLetter: "l",
43    // alphabetize: true,
44    page: currentPage,
45  },
46});
47console.log("totalUsersData: ", totalUsersData);
48
49useEffect(() => {
50  if (
51    !totalUsersData.loading &&
52    totalUsersData &&
53    totalUsersData.data &&
54    totalUsersData.data.GetTotalUsers &&
55    totalUsersData.data.GetTotalUsers.Users &&
56    totalUsersData.data.GetTotalUsers.Users.length
57  ) {
58    if (!isPaginated) {
59      const combinedUsers = [...totalUsers].concat(
60        totalUsersData.data.GetTotalUsers.Users
61      );
62      console.log("combinedUsers 1: ", combinedUsers);
63      // combinedUsers.push([...totalUsersData.data.GetTotalUsers.Users]);
64      // console.log("combinedUsers 2: ", combinedUsers);
65      setTotalUsers(combinedUsers);
66    } else {
67      setTotalUsers(totalUsersData.data.GetTotalUsers.Users);
68    }
69  }
70}, [totalUsersData]);
71
72const onClickPagination = (direction) => {
73  console.log("direction: ", direction);
74
75  if (direction === "prev") {
76    if (currentPage <= 1) {
77      alert("Page cannot go below 1");
78    } else {
79      setCurrentPage(currentPage - 1);
80    }
81  } else {
82    if (currentPage >= 2) {
83      alert(
84        "Page cannot go above 2 - the test api doesn't have data after 2 pages :("
85      );
86    } else {
87      setCurrentPage(currentPage + 1);
88    }
89  }
90};
91
92const onClickLazyLoading = () => {
93  if (currentPage >= 2) {
94    alert(
95      "Page cannot go above 2 - the test api doesn't have data after 2 pages :("
96    );
97  } else {
98    setCurrentPage(currentPage + 1);
99  }
100};
101
102const Pagination = () => {
103  return (
104    <div>
105      <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
106      <div style={{ display: "flex", flexDirection: "row" }}>
107        <div
108          onClick={() => onClickPagination("prev")}
109          style={{ cursor: "pointer" }}
110        >
111          {"<-- Previous Page"}
112        </div>
113        <div style={{ marginLeft: 10, marginRight: 10 }}> | </div>
114        <div
115          onClick={() => onClickPagination("next")}
116          style={{ cursor: "pointer" }}
117        >
118          {"Next Page -->"}
119        </div>
120      </div>
121      <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
122    </div>
123  );
124};
125
126const LazyLoading = () => {
127  return (
128    <div>
129      <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
130      <div onClick={onClickLazyLoading} style={{ cursor: "pointer" }}>
131        Load More
132      </div>
133      <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
134    </div>
135  );
136};
137
138return (
139  <div>
140    <div>Total Users</div>
141    <div style={{ marginTop: 10 }}>===============</div>
142    <div style={{ marginTop: 10 }}>Count: {totalUsers.length}</div>
143    <div style={{ marginTop: 10 }}>===============</div>
144    <div style={{ marginTop: 10 }}>Page: {currentPage}</div>
145    {totalUsersData.loading && (
146      <div>
147        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
148        <div>Loading...</div>
149      </div>
150    )}
151    {!totalUsersData.loading && !totalUsersData.error && !totalUsers.length && (
152      <div>
153        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
154        <div>No results returned for this request</div>
155      </div>
156    )}
157    {totalUsersData.error && totalUsersData.error.message && (
158      <div>
159        <div style={{ marginTop: 10, marginBottom: 10 }}>===============</div>
160        <div>Error: {totalUsersData.error.message}</div>
161      </div>
162    )}
163    {console.log("totalUsers 2: ", totalUsers)}
164    {totalUsers.map((el) => {
165      return (
166        <div key={el.id}>
167          <div style={{ marginTop: 10, marginBottom: 10 }}>
168            ===============
169          </div>
170          <div>ID: {el.id}</div>
171          <div>Email: {el.email}</div>
172          <div>First Name: {el.first_name}</div>
173          <div>Last Name: {el.last_name}</div>
174        </div>
175      );
176    })}
177    {totalUsers.length && isPaginated ? <Pagination /> : null}
178    {totalUsers.length && !isPaginated ? <LazyLoading /> : null}
179  </div>
180);
181}
182
183export default App;

So, you can switch between pagination functionality by turning the 'isPaginated' variable to true/false. By pressing the bottom buttons for either Pagination or LazyLoading, you are updating the components state and thus triggering the function to run again. This triggers the useQuery hook again and reruns it with a new page variable, and our totalUsers state value is updated. By leveraging Reacts state/rerender functionality we are able to achieve pagination and lazy loading with our GraphQL query.

This is a basic and crude example of how to achieve pagination. The ApolloClient hooks have more to them than I demonstrated here [6], but this should give you some ideas to get you started working with asynchronous data using React, GraphQl, ApolloClient hooks, AWS AppSync, and AWS Lambda.


Conclusion

You should now have a grasp on how to begin working with asynchronous data using React, GraphQl, ApolloClient hooks, AWS AppSync, and AWS Lambda. With these tools you can acomplish a lot when building a dynamic client-side application that needs to fetch asynchronous data. I hope I gave you some ideas to get you on your way while giving you a succinct article that doesn't completely overwhelm you.

These concepts can go a lot deeper, and I'm sure there are better ways to do the things than what I've demonstrated. However, this article is a proof of concept to get you started. It is by no means a completely encompassing tutorial. That would be a massively long article. You can leverage AppSync schemas and data sources to be a lot more powerful than what I have shown here. AppSync schemas support mutations, subscriptions, complex data relationships, and more. Supported AppSync data sources include Dynamo, Elasticsearch, Lambda, Relational databases, HTTP endpoint, and an option for no data source. To learn more I recommend exploring the articles referenced below.


If you notice any issues with this tutorial, please email me at kevinmurphywebdev (@) gmail (.) com so that I can update the article accordingly. Happy coding!


  1. https://reactjs.org/docs/create-a-new-react-app.html
  2. https://docs.aws.amazon.com/lambda/latest/dg/getting-started.html
  3. https://docs.aws.amazon.com/appsync/latest/devguide/quickstart.html
  4. https://graphql.org/learn/
  5. https://docs.aws.amazon.com/appsync/latest/devguide/designing-your-schema.html
  6. https://www.apollographql.com/docs/react/api/react/hooks/
Previous Post