How to Create a Serverless Web App with React and AWS
April 06, 2021
April 06, 2021
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> ); }
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 TabPanel
s 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> ); }
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 TextField
s 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> ) }
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", }
We can then head over to DynamoDB to verify that we’ve successfully put an item in our 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
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.
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), }
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> )
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!