Cloud

How to Create a Serverless Web App with React and AWS

Will Ervin, Senior Software Developer 

In my last blog we set up all the necessary AWS resources that we’ll use today to code our React application. If you haven’t had the chance to catch up on that blog post you can do so here before we dive in: Setting Up A Simple Serverless Web App With React and AWS.  For this example, we’ll use the Material UI framework to create a web app with two tabs. One to represent sending and one to represent retrieving data to and from our DynamoDB database.

SIDE NOTE

One good practice to follow is this; before getting started, convert all JavaScript files to TypeScript. This isn’t absolutely necessary, but it will save you a lot of time debugging and it’s super simple to add to your React application.

To install, run npm install --save typescript @types/node @types/react @types/react-dom @types/jest. Now, you will want to rename any Javascript files to be Typescript files by changing the file extensions (e.g. App.js to App.tsx) and then its time to restart your development server.

MAIN FUNCTION

First things first, we’ll start creating our UI in App.tsx. In our App() function, we’ll create an AppBar and add our Tabs to it.

function App() {
  return (
    <AppBar>
      <Tabs>
        <Tab label="Send" value="send" />
        <Tab label="Receive" value="receive" />
      </Tabs>
    </AppBar>
  );
}

Send and Receive Tabs

I’ve now gone and created two very basic “Send” and “Receive” forms in order to demonstrate the difference between the two tabs when switching. To switch between tabs, we’ll utilize React’s useState Hook that lets you add React state to your functions. Hooks, in part, are what make React so neat because previously, if you realized you wanted to add state to a function component, you had to convert it to a class and then define constructor and call super( props ). For this, we’ll declare a state variable currentTab and pass to useState the value of the tab we would like to use as the initial state. In this case: “send”. We’ll also create a const function changeTab, that takes an event and a string value, that will set currentTab to the corresponding tab value. We’ll add our own TabPanels to show the content belonging to the given tabs, and finally, we’ll wrap all of this in a TabContext.

function App() {

  const [currentTab, setCurrentTab] = useState("send");
  const changeTab = (_: ChangeEvent<{}>, value: string) => setCurrentTab(value);

  return (

    <TabContext value={currentTab}>
      <AppBar position="static">
        <Tabs onChange={changeTab} value={currentTab}>
          <Tab label="Send" value="send" />
          <Tab label="Receive" value="receive" />
        </Tabs>
      </AppBar>
      <TabPanel value="send">
        <Send />
      </TabPanel>
      <TabPanel value="receive">
        <Receive />
      </TabPanel>
    </TabContext>

  );
}

Send Tab

Receive Tab

SENDING DATA

Next we’ll edit our “Send” and “Receive” forms to interact with our Lambda function and DynamoDB database. If you’ll remember from my last blog, we set up our database using two columns: “name” as our partition key and “date” as our sort key. We also chose to use a NoSQL database so we could stick anything else in there that we wanted. Since “date” will be handled by our Lambda function all we’ll need to do is add a state variable name and a state variable data so that the user can submit a line of text as well. We’ll add two TextFields corresponding to each of the state variables, and we’ll add a Button so that we can send this data to our database.

export default function Send() {
    const [name, setName] = useState("");
    const [data, setData] = useState("");

    return (
        <Container>
            <Grid>
                <TextField
                    label="Name"
                    value={name}
                />
                <TextField
                    label="Data"
                    value={data}
                />
            </Grid>
            <Grid>
                <Button>
                    Send
                </Button>
            </Grid>
        </Container>
    )
}

Edited Form

I know it’s not pretty but we’re on the right track here. Our issue now is that our button and each text field is non-functional. If we try to type in a text field, nothing appears, and we haven’t told our button what to do. To add some functionality, we’ll need a function that updates the values of our state variables whenever anything is typed. This function will be called handleChange and will accept a string with the name of our variable and a React ChangeEvent containing the value that needs to be updated. This will then be assigned to the onChange prop for each text field.

const handleChange = (i: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
    let value = e.target.value;
    i === "name" ? setName(value) : setData(value)
}
<TextField
    label="Name"
    value={name}
    onChange={handleChange("name")}
/>
<TextField
    label="Data"
    value={data}
    onChange={handleChange("data")}
/>

To get our “Send” button working, it’s going to take some work on the back end to get our Lambda function to properly send data to our database, so we’ll hop on over to our index.py file. Here is where we will define two functions, allowing us to GET and POST data.

def send_data(event):
    pass

def receive_data(event):
    pass

def handler(event, _):
    functions = {"GET": receive_data, "POST": send_data}
    return functions[event["httpMethod"]](event)

We’ll start by sending data so we can add some functionality to our “Send” button. To connect to our database we will be using the “dynamodb” resource from the boto3 library.

dynamodb = boto3.resource("dynamodb")
db_table = dynamodb.Table("simplereactapp-dev")

Then, in our send_data function, we’ll extract the body from our handler event. That data, along with the date it was submitted and our Global Secondary Index “status”, will be put into our table. A GSI will be useful later when we want to retrieve our data, as it adds query flexibility. In order to test that we’ve set this Lambda function up properly we can run amplify push, head on over to API Gateway, and then test our POST method.

def send_data(event):
    body = json.loads(event["body"])
    db_table.put_item(Item={**body, "date": datetime.now().isoformat(), "status": "OK"})

    return {
        "isBase64Encoded": False,
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
        },
        "body": "OK",
    }

Lambda Function Test

DynamoDB Confirmation

 

We can then head over to DynamoDB to verify that we’ve successfully put an item in our table.

DynamoDB Table

So now let’s make our “Send” button functional. We’ll create a function sendData that constructs an object containing the data we want to send to our database and POSTs it. We’ll then assign this function to our buttons onClick prop.

const sendData = () => {
    const init = {
        body: {
            name,
            data
        }
    };
    API.post("api2142ff56", "/items", init);
}
<Button onClick={sendData}>
    Send
</Button>

Now, when we click our “Send” Button, our data is sent to our database.

Data Sent to DynamoDB

Data Sent to DynamoDB

Data Sent to DynamoDB Verified

RECEIVING DATA

The last thing we’ll need to do is retrieve data from our database and display it on our second tab. To do so, we’ll hop back into our index.py file. In our receive_data function we’ll be querying our database table.

Since we want to be able to query multiple items in our table, DynamoDB allows us to use a GSI to account for additional data access patterns. The reason we’re using a GSI rather than using DynamoDB’s scan operation is because scan doesn’t allow us to sort.  For this table I’ve gone and set up an index called “status” using our “status” column as a partition key and our “date” column as a sort key. To utilize our GSI, we’ll pass to query the IndexName and a KeyConditionalExpression, which in this case is saying return any row where “status” equals “OK”. Again, we can go into API Gateway and verify our GET method works properly.

GET Method Verified

def receive_data(event):
    response = db_table.query(IndexName="status", KeyConditionExpression=Key("status").eq("OK"), ScanIndexForward=False)
    return {
        "isBase64Encoded": False,
        "statusCode": 200,
        "headers": {
            'Content-Type': 'application/json',
            'access-control-allow-origin': '*'
            },
        "body": json.dumps(response),
    }

DynamoDB Query

Now, to display this data on our “Receive” form, we’ll define a state variable called data and a function getData. This function makes a GET call and uses the API response to set the state of our data variable. To make sure that our data object is always up to date, we can take advantage of React’s useEffect Hook. By passing getData to useEffect we’re telling React that we want to run this function after the first render and anytime the DOM updates.

const [data, setData] = useState<[] | Doc[]>([]);

const getData = () => {
    API.get("api2142ff56", "/items", {})
        .then((res) =>
            setData(
                res.Items.map((x: DocAPIResponse) => {
                    const { name, date, data } = x
                    return {
                        name: name,
                        date: date,
                        data: data
                    };
                })
            )
        )
};

useEffect(getData)

Then all that we need to do is display this data on our form. For each row in our table, we’ll create a Paper with the name and date on top and the data below.

return (
    <Container>
        <Grid container spacing={2}>
            {data.map((x: Doc) =>
                <Grid item xs={8}>
                    <Paper>
                        <Grid container direction="row" justify="space-between">
                            <Grid item>
                                {x.name}
                            </Grid>
                            <Grid item>
                                {new Date(x.date).toDateString()}
                            </Grid>
                        </Grid>
                        <Divider />
                        <Grid item>
                            {x.data}
                        </Grid>
                    </Paper>
                </Grid>
            )
            }
        </Grid>
    </Container>
)

FInished React Form

TO WRAP IT UP…

And there you have it. In no time we’ve set up two forms using React that interact with all the resources we constructed last time. This is a very simple example, but hopefully this gives you a better understanding of React and how it can interact with AWS architecture. From here I would recommend adding a little DevSecOps to your application to add a layer of security and automation. My colleague John Partee wrote a great blog explaining an easy way to do just that. You can go check that out here: DevSecOps, the Pareto Principle, and You!