Hello, Habr! I present to you the translation of the article
“How to Build and Deploy a Full-Stack React-App” by Frank Zickert.
Infrastructure components make it easy to create, run, and deploy a full-fledged React application. With these React components, you can focus on writing the business logic of your application. You do not need to worry about its configuration.
Want to become a full-stack developer? The full-stack application complements the interactive React web interface with a server and database. But such an application requires much more settings than a simple one-page application.
We use
infrastructure components . These React Components allow us to define our infrastructure architecture as part of our React application. We no longer need any other settings, such as Webpack, Babel or Serverless.
Start
You can set up your project in three ways:
Once you have installed the dependencies (run
npm install
), you can build the project with one command:
npm run build
.
The build script adds three more scripts to package.json:
npm run{your-project-name}
launches your React application locally (hot-dev mode, without server part and database)npm run start-{your-env-name}
starts the entire software stack locally. Note: Java 8 JDK is required to run the database offline. Here is how you can install the JDK .npm run deploy-{your-env-name}
deploys your application to AWS.
Note. To deploy your application to AWS, you need an IAM technical user with these rights. Put the user credentials in your .env file as follows:
AWS_ACCESS_KEY_ID = *** AWS_SECRET_ACCESS_KEY = ***
Define your application architecture
Projects based on infrastructure components have a clear structure. You have one top-level component. This defines the overall architecture of your application.
Subcomponents (child components) refine (extend) the behavior of the application and add functions.
In the following example, the
<ServiceOrientedApp />
component is our top-level component. We export it as the default file to our entry point file (
src / index.tsx)
.
export default ( <ServiceOrientedApp stackName = "soa-dl" buildPath = 'build' region='eu-west-1'> <Environment name="dev" /> <Route path='/' name='My Service-Oriented React App' render={()=><DataForm />} /> <DataLayer id="datalayer"> <UserEntry /> <GetUserService /> <AddUserService /> </DataLayer> </ServiceOrientedApp> );
<ServiceOrientedApp />
is an interactive web application. You can clarify (expand) the functionality of this application using the child components you provided. It supports the
<Environment />
,
<Route />
,
<Service />
and
<DataLayer />
.
<Envrionment />
determines the runtime of your application. For example, you can have dev and prod version. You can run and deploy each separately.
<Route />
is the page of your application. it works like
<Route />
in react-router.
Here is a tutorial on how to work with routes .
<Service />
defines a function that runs on the server side. It can have one or several
<Middleware />
- components as children.
<Middleware />
works like Express.js-middleware.
<DataLayer />
adds a NoSQL database to your application. Accepts <Entry /> - components as children. <Entry /> describes the type of items in your database.
These components are all we need to create our full-stack application. As you can see, our application has: one runtime, one page, two services and a database with one record.
The component structure provides a clear view of your application. The larger your application becomes, the more important it is.
You may have noticed that
<Service />
are children of
<DataLayer />
. This has a simple explanation. We want our services to have access to the database. It's really that simple!
Database Design
<DataLayer />
creates Amazon DynamoDB. This is a key-value database (NoSQL). It provides high performance on any scale. But unlike relational databases, it does not support complex queries.
The database schema has three fields:
primaryKey
,
rangeKey
and
data
. This is important because you need to know that you can only find entries by its keys. Either by
primaryKey
, or by
rangeKey
, or both.
With this knowledge, let's take a look at our
<Entry />
:
export const USER_ENTRY_ID = "user_entry"; export default function UserEntry (props) { return <Entry id={ USER_ENTRY_ID } primaryKey="username" rangeKey="userid" data={{ age: GraphQLString, address: GraphQLString }} /> };
<Entry />
describes the structure of our data. We define names for our primaryKey and rangeKey. You can use any name other than some DynamoDB keywords that you can find here. But the names we use have functional implications:
- When we add items to our database, we must provide values for these key names.
- The combination of both keys describes a unique element in the database.
- There should not be another <Entry /> with the same key names (one name may be the same, but not both).
- We can find elements in the database only when we have the value of at least one key name.
In our example, this means that:
- Each User must have username and userid.
- there cannot be a second User with the same username and the same userid. From a database perspective, it would be nice if two User had the same username when they have different userid (or vice versa).
- We cannot have another <Entry /> in our database with primaryKey = "username" and rangeKey = "userid".
- We can query the database for users when we have a username or user
id
. But we cannot request by age or address.
Add items to the database
We defined two
<Service />
components in our
<ServiceOrientedApp />
.
POST
service that adds the user to the database and
GET
service that retrieves the user from it.
Let's start with
<AddUserService />
. Here is the code for this service:
import * as React from 'react'; import { callService, Middleware, mutate, Service, serviceWithDataLayer } from "infrastructure-components"; import { USER_ENTRY_ID, IUserEntry } from './user-entry'; const ADDUSER_SERVICE_ID = "adduser"; export default function AddUserService () { return <Service id={ ADDUSER_SERVICE_ID } path="/adduser" method="POST"> <Middleware callback={serviceWithDataLayer(async function (dataLayer, req, res, next) { const parsedBody: IUserEntry = JSON.parse(req.body); await mutate( dataLayer.client, dataLayer.setEntryMutation(USER_ENTRY_ID, parsedBody) ); res.status(200).set({ "Access-Control-Allow-Origin" : "*", // Required for CORS support to work }).send("ok"); })}/> </Service> };
The
<Service />
component - accepts three parameters:
- The identifier (
id
) must be a unique string. We use an identifier ( id
) when we need to call service
in other components. - The
path
(with initial /) indicates the relative URL path of your service - The
method
must be one of GET
, POST
, UPDATE
, DELETE
. It indicates the HTTP request that we use when calling the service.
We add
<Middleware />
as a child. This
<Middleware />
takes a callback function as a parameter. We could directly provide Express.js middleware. Since we want to access the database, we
serviceWithDataLayer
function in
serviceWithDataLayer
. This adds
dataLayer
as the first parameter to our callback.
DataLayer
provides access to the database. Let's see how!
The
mutate
asynchronous function applies the changes to the data in our database. This requires a client and a mutation command as parameters.
Element data is a Javascript object that has all the necessary key-value pairs. In our service, we get this object from the request body. For
User
object has the following structure:
export interface IUserEntry { username: string, userid: string, age: string, address: string }
This object accepts the names
primaryKey
and
rangeKey
and all the data keys that we defined in
<Entry />
.
Note: for now, the only supported type is a string that matches the GraphQLString in the definition of <Entry />.
We mentioned above that we take
IUserEntry
data from the body. How does this happen?
Infrastructure components provide the asynchronous function
callService (serviceId, dataObject)
. This function accepts a service identifier, a Javascript object (for sending as a request body when using
POST
), a
success
function, and an error callback function.
The following snippet shows how we use this function to call our
<AddUserService />
. We specify
serviceId
. And we pass
userData
, which we take as a parameter to our function.
export async function callAddUserService (userData: IUserEntry) { await callService( ADDUSER_SERVICE_ID, userData, (data: any) => { console.log("received data: ", data); }, (error) => { console.log("error: " , error) } ); };
Now the
callAddUserService
function is all we need when we want to add a new user. For example, call it when the user clicks a button:
<button onClick={() => callAddUserService({ username: username, userid: userid, age: age, address: address })}>Save</button>
We simply call it using the
IUserEntry
object. It calls the correct service (as indicated by its identifier (
id
)). It puts
userData
in the request body.
<AddUserService />
takes data from the body and puts it in the database.
Get items from the database
Retrieving items from a database is as easy as adding them.
export default function GetUserService () { return <Service id={ GETUSER_SERVICE_ID } path="/getuser" method="GET"> <Middleware callback={serviceWithDataLayer(async function (dataLayer, req, res, next) { const data = await select( dataLayer.client, dataLayer.getEntryQuery(USER_ENTRY_ID, { username: req.query.username, userid: req.query.userid }) ); res.status(200).set({ "Access-Control-Allow-Origin" : "*", // Required for CORS support to work }).send(JSON.stringify(data)); })}/> </Service> }
Again, we use <Service />, <Middleware /> and a callback function with access to the database.
Instead of the
mutate
function, which adds an item to the database, we use the
select
function. This function asks for the client we are taking from
dataLayer
. The second parameter is the
select
command. Like the
mutation
command, we can create a
select
command using
dataLayer
.
This time we use the
getEntryQuery
function. We provide the identifier (
id
)
<Entry />
whose element we want to receive. And we provide the keys (
primaryKey
and
rangeKey
) of a particular element in a Javascript object. Since we provide both keys, we get one element back. If it exists.
As you can see, we take the key values from the request. But this time we take them from
request.query
, and not from
request.body
. The reason is that this service uses the
GET
method. This method does not support the body in the request. But it provides all the data as query parameters.
The
callService
function handles this for us. As in the
callAddUserService-function
, we provide the identifier (
id
)
<Service />
that we want to call. We provide the necessary data. Here it is only the keys. And we provide callback functions.
A successful callback provides a response. The response body in json format contains our element found. We can access this element through the
get_user_entry
key. "
Get_
" defines the query that we placed in our select function. "
User_entry
" is the key of our
<Entry />
.
export async function callGetUserService (username: string, userid: string, onData: (userData: IUserEntry) => void) { await callService( GETUSER_SERVICE_ID, { username: username, userid: userid }, async function (response: any) { await response.json().then(function(data) { console.log(data[`get_${USER_ENTRY_ID}`]); onData(data[`get_${USER_ENTRY_ID}`]); }); }, (error) => { console.log("error: " , error) } ); }
Take a look at your Full-Stack app in action.
If you haven't started your application yet, now is the time to do it:
npm run start-{your-env-name}
.
You can even deploy your application to AWS with a single command:
npm run deploy-{your-env-name}
. (Remember to put the AWS credentials in the .env file).
This post does not describe how you enter the data that you put into the database and how you display the results.
callAddUserService
and
callGetUserService
encapsulate everything that is specific to services and the database. You just put the Javascript object there and get it back.
You will find the source code for this example in this
GitHub repository . It includes a very simple user interface.