GraphQL with React
Introduction
I have been reading this book on React 18 and its best practices. As I go through this book, I will distill the core pieces of the most interesting tid-bits in this series of articles on React. In this article we will create a backend GraphQL service to facilitate user login management and use the topics we have learned thus far to create a production grade front end React App to interface with the backend.
Since this chapter was quite long and had a lot of inter-dependent code, I have put the whole project on the github link below.
Backend
We will take a step away from our React focused journey and learn about building an API using GraphQL and postgres.
Let’s get some definitions out of the way…
GraphQL is a query language for APIs that allows clients to request exactly the data they need, efficiently retrieving it in a single request. It provides a more flexible and efficient alternative to REST APIs by enabling developers to define the structure of the responses from the server.
PostgreSQL — often referred to as Postgres — is a powerful, open-source relational database management system (RDBMS) known for its advanced features, high performance, and extensibility. It supports SQL for querying and managing data, along with JSON for non-relational queries, making it versatile for various types of applications.
Setting up a Postgres Instance
The first step in our application journey is setting up a postgres instance, you can do this however you want so long as the backend node server we build can access it via a url.
I am on a mac, so I simply followed these instructions using Homebrew https://wiki.postgresql.org/wiki/Homebrew.
Setting up Configuration
We will need to set up a few config files to get our server working properly. First, let’s add a dev script to package.json which we will use to run our server.
"scripts": {
"dev": "ts-node-dev src/index.ts",
}
In your backend root directory, you will also need to create a .env file where we will store some application secrets. Below is my example…
DB_DIALECT=postgres
DB_PORT=5432
DB_HOST=localhost
DB_DATABASE=login
DB_USERNAME=macfarqu
DB_PASSWORD=password
Now, we will also store config values in a file located at config/config.json
{
"server": {
"port": 4000
},
"security": {
"secretKey": "C0nt3ntP1",
"expiresIn": "7d"
}
}
We could also probably just put these in the .env file instead — which is normally the best practice for a production site and works better with container management systems like Kubernetes.
Finally, we will use these values to construct and export three constructs which will be used by our app: $db, $security and $server.
import dotenv from "dotenv";
import config from "./config.json";
dotenv.config();
type DB = {
dialect: string,
host: string,
port: string,
database: string,
username: string,
password: string
}
type Security = {
secretKey: string,
expiresIn: string
}
type Server = {
port: number
}
const db: DB = {
dialect: process.env.DB_DIALECT || '',
port: process.env.DB_PORT || '',
host: process.env.DB_HOST || '',
database: process.env.DB_DATABASE || '',
username: process.env.DB_USERNAME || '',
password: process.env.DB_PASSWORD || ''
};
const { security, server } = config;
export const $db: DB = db;
export const $security: Security = security;
export const $server: Server = server;
Data Types & Interfaces
We are going to need some type defs and interfaces in our app. Let’s define them now.
types/types.ts
export type User = {
username: string
password: string
email: string
role: string
active: boolean
}
export type Sequelize = {
_defaults?: any,
name?: string,
options?: any,
associate?: any
}
types/interface.ts
import { Sequelize, User } from "./types";
export interface IDataTypes {
UUID: string,
UUIDV4(): string,
STRING: string,
BOOLEAN: boolean,
TEXT: string,
INTEGER: number,
DATE: string,
FLOAT: number
}
export interface IUser extends User, Sequelize {
id: string,
token?: string,
createdAt?: Date,
updatedAt?: Date
}
export interface ICreateUserInput extends User {}
export interface ILoginInput {
email: string,
password: string
}
export interface IToken {
token: string
}
export interface IModels {
User: any
sequelize: any
}
The first file — types.ts — defines some constructs which will be extended by our interfaces (we could probably just make these interfaces as well and put them all in one file).
The interface.ts file creates some objects which we will use to interact with postgres and GraphQL.
Postgres Models
To use postgres, we will use sequelize to define our SQL schemas using ts. We only have one model at this time, it is called User
import { encrypt } from "@contentpi/lib";
import { IDataTypes, IUser } from "../types";
export default (sequelize: any, dataTypes: IDataTypes): IUser => {
const user = sequelize.define('User', {
id: {
primaryKey: true,
allowNull: false,
type: dataTypes.UUID,
defaultValue: dataTypes.UUIDV4()
},
username: {
type: dataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isAlphanumeric: {
args: true,
msg: "The user just accepts alphanumeric characters"
},
len: {
args: [4, 20],
msg: "The username must be from 4 to 20 characters"
}
}
},
password: {
type: dataTypes.STRING,
allowNull: false
},
email: {
type: dataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: {
args: true,
msg: "Invalid email"
}
}
},
role: {
type: dataTypes.STRING,
allowNull: false,
defaultValue: "user"
},
active: {
type: dataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{
hooks: {
beforeCreate: (user: IUser): void => {
user.password = encrypt(user.password)
}
}
}
);
return user;
}
Using sequelize we can use json to define fields for our schema, create validation requirements (like length being a certain amount, or the value being an email) and inject hooks to be called before the input is added to the database (like encrypting the password field).
We combine our models with a sequelize object that has access to our postgres instance and export a single IModels object from models/index.ts
import { Sequelize } from "sequelize";
import { $db } from "../../config";
import { IModels } from "../types";
const { dialect, port, host, database, username, password } = $db;
const uri = `${dialect}://${username}:${password}@${host}:${port}/${database}`;
const sequelize = new Sequelize(uri);
const models: IModels = {
User: require('./User').default(sequelize, Sequelize),
sequelize
}
export default models;
With this setup, we will now be able to interface with our postgres instance using this exported models variable.
Authentication & Authorization
Since we are building an app with login management, we should use some authentication and authorization. To achieve this we will use JWT.
When a user successfully authenticates themselves, a server will create a JWT by encoding a payload (containing claims about an entity and additional data) and a header (specifying the token type and signing algorithm), then signing these with a secret or private key. This token can then be provided by the client in future requests instead of having to re-authenticate.
import { encrypt, getBase64, setBase64 } from "@contentpi/lib";
import jwt from "jsonwebtoken";
import { $security } from "../../config";
import { IUser } from "../types";
const { secretKey } = $security;
export function jwtVerify(accessToken: string, cb: any): void {
jwt.verify(accessToken, secretKey, (error: any, accessTokenData: any = {}) => {
const {data: user} = accessTokenData;
if (error || !user) {
return cb(false);
}
const userData = getBase64(user);
return cb(userData)
})
}
export async function getUserData(accessToken: string): Promise<any> {
const userPromise = new Promise((resolve) => jwtVerify(accessToken, (user: any) => resolve(user)));
const user = await userPromise;
return user;
}
export const createToken = async (user: IUser): Promise<string[]> => {
const { id, username, password, email, role, active } = user;
const token = setBase64(`${encrypt($security.secretKey)}${password}`);
const userData = {
id,
username,
email,
role,
active,
token
};
const createdToken = jwt.sign({data: setBase64(userData)}, $security.secretKey, {expiresIn: $security.expiresIn});
return Promise.all([createdToken]);
}
The lib/jwt.ts file exports three functions.
createToken is used after a user has logged in, the token contains user information and an expiration date.
jwtVerify will take in a provided access token and check if the token is valid, if so it will return the user data which is encoded in the token.
getUserData is a wrapper around jwtVerify which will decode and return the user for us.
Our auth.ts handles logging in and — for some reason — accessing a user from the database.
import { encrypt, isPasswordMatch } from "@contentpi/lib";
import { IToken, IModels, IUser } from "../types";
import { createToken } from "./jwt";
export const getUserBy = async (where: any, models: IModels): Promise<IUser> => {
const user = await models.User.findOne({
where,
raw: true
});
return user;
}
export const doLogin = async (email: string, password: string, models: IModels): Promise<IToken> => {
const user = await getUserBy({email: email}, models);
if (!user) {
throw new Error("Invalid Login");
}
const passwordMatch = isPasswordMatch(encrypt(password), user.password);
if (!passwordMatch) {
throw new Error("Invalid Login");
}
const isActive = user.active;
if (!isActive) {
throw new Error("Your account is not activated yet");
}
const [token] = await createToken(user);
return {
token
}
}
getUserBy should probably be in its own file — maybe alongside other SQL queries — but since this is such a small app, the book put it here since it is tangentially related to auth.
doLogin will be called when a user first logs in. If everything is valid, we will create and return a JWT for the requestor to use on subsequent requests to restricted routes.
GraphQL API
Now its time to build our GraphQL API, we will first define all the types we will use in our API and then we will create our resolvers.
graphql/types/Scalar.ts
import gql from 'graphql-tag';
export default gql`
scalar UUID
scalar Datetime
scalar JSON
`
graphql/types/User.ts
import gql from "graphql-tag";
export default gql`
type User {
id: UUID!
username: String!
email: String!
password: String!
role: String!
active: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
}
type Query {
getUser(accessToken: String!): User!
getUsers: [User!]
}
type Mutation {
createUser(input: CreateUserInput): User!
login(input: LoginInput): Token!
}
type Token {
token: String!
}
input CreateUserInput {
username: String!
password: String!
email: String!
active: Boolean!
role: String!
}
input LoginInput {
email: String!
password: String!
}
`
We use scalars to define custom types which can pretty much be anything we want.
Types are like classes and will appear as fields and return values in our Queries and Mutations. The Query and Mutation types are reserved GraphQL types and hold our resolver definitions. You can think of the Queries like the GET routes and the Mutations as all the other route types.
Inputs are like types but are only used as parameters for queries and/or mutations.
We combine and export our type defs in a single export from graphql/types/index.ts
import { mergeTypeDefs } from "@graphql-tools/merge";
import Scalar from "./Scalar";
import User from "./User";
export default mergeTypeDefs([Scalar, User]);
We now have to define how we resolve our queries and mutations. These live in a file called graphql/resolvers/user.ts
import { doLogin, getUserBy } from '../../lib/auth'
import { getUserData } from '../../lib/jwt'
import { ICreateUserInput, ILoginInput, IModels, IToken, IUser } from '../../types'
export default {
Query: {
getUsers: (_parent: any, _args: any, context: {models: IModels}): IUser[] => context.models.User.findAll(),
getUser: async (_parent: any, args: {accessToken: string}, context: {models: IModels}): Promise<any> => {
const connectedUser = await getUserData(args.accessToken);
if (connectedUser) {
const user = await getUserBy(
{
id: connectedUser.id,
email: connectedUser.email,
active: connectedUser.active
},
context.models
);
if (user) {
return {...connectedUser};
}
} else {
return {
id: "",
username: "",
email: "",
role: "",
active: false
};
}
}
},
Mutation: {
createUser: (_parent: any, args: {input: ICreateUserInput}, context: {models: IModels}): IUser => context.models.User.create({...args.input}),
login: (_parent: any, args: {input: ILoginInput}, context: {models: IModels}): Promise<IToken> => doLogin(args.input.email, args.input.password, context.models)
}
}
getUsers uses our postgres db stored inside our models object to query for all users.
getUser is a restricted route, so we first verify the JWT that was provided using getUserData. Then, if the token is valid, we retrieve and return the logged in user information.
createUser will use our models interface to create a user from the given ICreateUserInput argument and returns the result of the query.
login will call our doLogin auth function and returns the JWT if successful.
Starting the Server
That was a lot of work, but we now have our database, authentication utils, and GraphQL specification all ready to use in a server.
import express from "express";
import cors from "cors";
import http from 'http'
import { makeExecutableSchema } from "@graphql-tools/schema";
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import { expressMiddleware } from '@apollo/server/express4'
import { applyMiddleware } from "graphql-middleware";
import { ApolloServer } from "@apollo/server";
import typeDefs from "./graphql/types";
import resolvers from "./graphql/resolvers";
import models from './models'
import { json } from "body-parser";
import { $server } from "../config";
const app = express();
const corsOptions = {
origin: "*",
credentials: true
};
app.use(cors(corsOptions));
app.use((_req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
next();
});
const httpServer = http.createServer(app);
const schema = applyMiddleware(
makeExecutableSchema({
typeDefs: typeDefs,
resolvers: resolvers
})
);
const apolloServer = new ApolloServer({
schema: schema,
plugins: [ApolloServerPluginDrainHttpServer({httpServer: httpServer})]
});
const main = async () => {
const alter = true;
const force = true;
await apolloServer.start();
await models.sequelize.sync({alter: alter, force: force});
app.use(
"/graphql",
cors<cors.CorsRequest>(),
json(),
expressMiddleware(apolloServer, {
context: async () => ({models: models})
})
);
await new Promise<void>((resolve) => httpServer.listen({port: $server.port}, resolve));
console.log(`🚀 Server ready at http://localhost:${$server.port}/graphql`);
}
main();
We set up our express app and wrap it in an ApolloServer which will allow us to use GraphQL. On startup, we force the postgres table to synchronize structure with our models. Then we create a single POST endpoint called graphql which will handle all requests and map them to the proper resolvers (Query or Mutation).
At this point, you can play around in the GraphQL playground by going to http://localhost:4000/graphql and execute different queries and mutations that we have defined.
Summary
- We can use Sequelize to interface with postgres, creating verbose validation and logic hooks as well as SQL structure all with JSON and then craft model objects we can use to invoke SQL queries.
- We can use JWT to send a token back to the client when they preform a login, the client can use that token in place of having to re-authenticate for future requests while the token is not expired.
- We can set up GraphQL by defining some types and resolver logic for Queries (GET) and Mutations (POST, PUT, DELETE,…) and use ApolloServer to set up a single POST endpoint to handle all the incoming requests.
Frontend (Server Side)
We now have a functioning backend, however it is not very fun to just play around in the GraphQL playground, we will now build a front end application to talk to the server.
I diverged a lot from the book at this point, their front end setup was misconfigured and the code examples in the github and book had multiple unresolved imports and issues.
Configuration
We are going to use webpack to create a bundle.js from our actual React app and a server.js file which will serve the html with the bundle.js as well as some non-visual routes to preform things like logging out.
const nodeExternals = require("webpack-node-externals");
const path = require("path");
const { ProvidePlugin } = require("webpack");
const CopyPlugin = require('copy-webpack-plugin');
const rules = [{
test: /\.(tsx|ts)$/,
use: 'ts-loader',
exclude: /node_modules/
}];
const client = {
mode: "production",
entry: "./src/client/client.tsx",
output: {
path: path.resolve(__dirname, './dist/public'),
filename: 'bundle.js',
publicPath: "/"
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"],
fallback: {
crypto: require.resolve('crypto-browserify'),
buffer: require.resolve('buffer/'),
stream: require.resolve('stream-browserify'),
vm: require.resolve("vm-browserify"),
os: require.resolve("os-browserify/browser"),
path: require.resolve("path-browserify")
}
},
plugins: [
new ProvidePlugin({
process: 'process/browser',
})
],
module: {
rules: rules
}
};
const server = {
mode: "production",
entry: "./src/server/server.ts",
output: {
path: path.resolve(__dirname, './dist'),
filename: 'server.js',
publicPath: "/"
},
module: {
rules: rules
},
target: "node",
externals: [nodeExternals()],
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
},
plugins: [
new CopyPlugin({
patterns: [
{ from: '.env', to: '.' }
],
}),
]
};
module.exports = [client, server];
Our webpack.config.js has two exports.
Server is a simple webpacking of our server.ts which will live at src/server/server.ts. We will save the bundled js to dist/server.js. We also copy the .env file which the server.js needs in order to run properly.
The client webpacking is similar but we have a few resolve fallbacks so we can poly-fill for node dependencies which webpack does not include by default and we add a special plugin to support using process in the browser. Our resulting bundle.js will live in dist/public/bundle.js
In package.json, we will use build and start to webpack and run our app.
"scripts": {
"build": "webpack",
"start": "node ./dist/server"
},
We also have a config file which has som constants used across our app.
import * as dotenv from 'dotenv';
dotenv.config();
type API = {
uri: string
}
type Securtiy = {
secretKey: string
expiresIn: string
}
export const isProduction: boolean = process.env.NODE_ENV === 'production';
export const isDevelopment: boolean = !isProduction;
const devUrl = "localhost";
const prodUrl = "localhost";
export const PORT = 3000;
export const DEV_SERVER_PORT = 3001;
export const GRAPHQL_PORT = 4000;
export const GRAPHQL_SERVER = isDevelopment ? devUrl : prodUrl;
export const baseUrl: string = isProduction ? `https://${prodUrl}:${PORT}` : `http://${devUrl}:${PORT}`;
export const publicPath: string = isProduction ? '' : `http://${devUrl}:${DEV_SERVER_PORT}/`;
export const api: API = {
uri: `http://${GRAPHQL_SERVER}:${GRAPHQL_PORT}/graphql`
}
export const security: Securtiy = {
secretKey: process.env.SECURITY_SECRET_KEY || "",
expiresIn: '7d'
}
We should have one item in our .env file, the secretKey which matches the one from the config.json in our backend which we will use for verifying the JWT.
Auth Middleware
Since we have configured our app to use JWT, we can craft endpoints that require some claims extracted from the JWT. Instead of re-writing the same logic each time or calling some helper function at the start of our routes, a common practice is to create a middleware to handle authentication which will take in the client request before the route handler, preform some operations (maybe even change the request object) and then pass the request on to the next middleware in the chain or to the handler for the route.
Below is our server/lib/jwt.ts file it is almost exactly the same as what we have on the backend — perhaps these two codebases could share this common functionality.
import { getBase64 } from "@contentpi/lib"
import jwt from "jsonwebtoken";
import * as config from "../../config";
const { security: {secretKey}} = config;
export function jwtVerify(accessToken: string, cb: any) {
jwt.verify(accessToken, secretKey, (error: any, accessTokenData: any = {}) => {
const {data: user} = accessTokenData;
if (error || !user) {
return cb(null)
}
const userData = getBase64(user);
return cb(userData);
})
}
export async function getUserData(accessToken: string): Promise<any> {
const userPromise = new Promise((resolve) => jwtVerify(accessToken, (user: any) => resolve(user)));
const user = await userPromise;
return user;
}
We use the jwt functionality in server/lib/middlewares/user.ts
import { NextFunction, Request, Response } from "express";
import { getUserData } from "../jwt";
export const isConnected = (isLogged = true, roles = ['user'], redirectTo = '/') => async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = await getUserData(req.cookies.at)
if (!user && !isLogged) {
return next()
} else if (user && isLogged) {
if (roles.includes('god') && user.privilege === 'god') {
return next()
} else if (roles.includes('admin') && user.privilege === 'admin') {
return next()
} else if (roles.includes('user') && user.privilege === 'user') {
return next()
}
res.redirect(redirectTo)
} else {
res.redirect(redirectTo);
}
}
We create an isConnected middleware which will try to get the user.
If the user is unauthorized and we don’t want this route to be accessible for logged in users, we can continue.
Else if the user is logged in and we require being logged in, then we can check if the user has the correct privileges for the route. If so, we go to next() and the user gets to see the route. If they do not, they are re-directed to the specified redirectTo url.
All other combinations (authenticated user and only non-auth route or non-auth user and auth only route) we will redirect the user.
The Server
The front end server will provide some routes and in some cases return our client side app html.
import express, {Application, Request, Response} from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import path from "path";
import template from "./template";
import * as config from "../config";
import { isConnected } from "./lib/middlewares/user";
const app: Application = express();
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser(config.security.secretKey));
app.use(cors({credentials: true, origin: true}));
app.use(express.static(path.resolve(__dirname, "./public")));
app.get("/login", isConnected(false), (_req: Request, res: Response) => {
const html = template();
res.send(html);
})
app.get("/logout", (req: Request, res: Response) => {
const redirect: any = req.query.redirectTo || "/";
res.clearCookie("accessToken")
res.redirect(redirect)
});
app.get("*", (_req: Request, res: Response) => {
const html = template();
res.send(html);
})
app.listen(3000, () => {
console.log('Listening on port 3000');
})
We can see our middleware is in use for the login route, restricting it only for non-authenticated user, authenticated users will be sent to the home page.
logout will redirect the user to the home page and clear the accessToken cookie we had saved (if any).
All other routes will just return our app without any authentication checking by the server.
The template used to generate the sent back html lives inside the template.ts file.
export default () => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</
`
Which just returns an html that will load our app bundle.
Summary
- We can use webpack to create rules for bundling server side code and client side bundle.js files in one go
- We can add middleware to our server routes to preform actions like authentication verification
Frontend (Client Side)
Now we can focus on our client side code which will be webpacked into bundle.js and run client side.
User Context
Much of our app focuses on user management, so it makes sense to have a userContext which we will use to inject user state throughout our components.
import { useMutation, useQuery } from "@apollo/client";
import { getGraphQlError, redirectTo } from "@contentpi/lib";
import { createContext, FC, ReactElement, useEffect, useState } from "react";
import { useCookies } from "react-cookie";
import GET_USER_QUERY from "../graphql/user/getUser.query";
import LOGIN_MUTATION from "../graphql/user/login.mutation";
interface IUserContext {
login(input: any): any
connectedUser: any
}
interface IProps {
page?: string
children: ReactElement
}
export const UserContext = createContext<IUserContext>({
login: () => null,
connectedUser: null
});
const UserProvider: FC<IProps> = ({page = "", children}) => {
const [cookies, setCookie] = useCookies();
const [connectedUser, setConnectedUser] = useState(null);
const [loginMutation] = useMutation(LOGIN_MUTATION);
const { data: dataUser } = useQuery(GET_USER_QUERY, {
variables: {
accessToken: cookies.accessToken || ''
}
});
useEffect(() => {
if (!dataUser) return;
if (!dataUser.getUser.id && page != "login") {
redirectTo(`/login?redirectTo=/${page}`)
} else {
setConnectedUser(dataUser.getUser);
}
}, [dataUser, page])
async function login(input: {email: string, password: string}): Promise<any> {
try {
const { data: dataLogin} = await loginMutation({
variables: {
email: input.email,
password: input.password
}
})
if (dataLogin) {
setCookie('accessToken', dataLogin.login.token, {path: "/"});
return dataLogin.login.token;
}
} catch (err) {
return getGraphQlError(err);
}
}
const context = {
login: login,
connectedUser: connectedUser
}
return <UserContext.Provider value={context}>{children}</UserContext.Provider>
}
export default UserProvider;
Our UserContext will export a function to preform a login given an email and password. It will also export the connectedUser (if one exists). We utilize the Login Mutation defined at graphql/user/login.mutation.ts
import { gql } from "@apollo/client"
export default gql`
mutation login($email: String!, $password: String!) {
login(input: {email: $email, password: $password}) {
token
}
}
`
and the gestUser query at graphql/user/getUser.query.ts
import { gql } from "@apollo/client";
export default gql`
query getUser($accessToken: String!) {
getUser(accessToken: $accessToken) {
id
email
username
role
active
}
}
`
When the dataUser changes or the page changes, we fire our useEffect. If we are unauthenticated and we are on any page other than login, we redirect to the login page with a query param to redirect back to the page we tried to get into if we successfully log in.
If we do have an authenticated user, we set them as the connectedUser.
Calling login will invoke the GraphQL Mutation login and — if successful — store the JWT in a cookie for us and return it as well.
Dashboard Components
Our Dashboard consists of an outer Layout component and a pure functional dashboard.
import { FC, useContext } from "react";
import { UserContext } from "../../contexts/user";
import Dashboard from "./Dashboard";
const Layout: FC = () => {
const { connectedUser } = useContext(UserContext);
if (connectedUser) {
return <Dashboard connectedUser={connectedUser} />
} else {
return <div/>
}
}
export default Layout;
The layout grabs the connectedUser from our context and injects it into our Dashboard.
interface IProps {
connectedUser: any
}
const Dashboard = ({connectedUser}: IProps) => (
<div className="dashboard">
<h1>Welcome, {connectedUser.username}</h1>
<ul>
<li><a href="/logout">Logout</a></li>
</ul>
</div>
)
export default Dashboard;
The component itself is extremely simple and shows our username and a link to logout.
Login Components
Our Login component takes the same approach as the dashboard setup: an outer layout component to manage getting props which it passes into the render component.
import { FC, useContext } from "react"
import { UserContext } from "../../contexts/user";
import Login from "./Login"
interface IProps {
currentUrl: string
}
const Layout: FC<IProps> = ({ currentUrl }) => {
const { login } = useContext(UserContext)
return <Login login={login} currentUrl={currentUrl}/>
}
export default Layout
We pass along the login function and the url to navigate to on a successful login.
import { ChangeEvent, FC, useState } from "react"
import { StyledLogin } from "./Login.styled";
import { useNavigate } from "react-router-dom";
interface IProps {
login(input: any): any
currentUrl: string
}
const Login: FC<IProps> = ({login, currentUrl}) => {
const navigate = useNavigate();
const [values, setValues] = useState({
email: '',
password: ''
});
const [errorMessage, setErrorMessage] = useState('');
const [invalidLogin, setInvalidLogin] = useState(false);
const onChange = (e: ChangeEvent<HTMLInputElement>): void => {
const { target: {name, value}} = e;
if (name) {
setValues((prevValues: any) => ({
...prevValues,
[name]: value
}));
}
}
const handleSubmit = async (user: any): Promise<void> => {
const response = await login(user);
if (response.error) {
setInvalidLogin(true)
setErrorMessage(response.message);
} else {
navigate(currentUrl || "/")
}
}
return (
<>
<StyledLogin>
<div className="wrapper">
{invalidLogin && <div className="alert">{errorMessage}</div>}
<div className="form">
<p>
<input
autoComplete="off"
type="email"
className="email"
name="email"
placeholder="Email"
onChange={onChange}
value={values.email}
/>
</p>
<p>
<input
autoComplete="off"
type="password"
className="password"
name="password"
placeholder="password"
onChange={onChange}
value={values.password}
/>
</p>
<div className="actions">
<button name="login" onClick={() => handleSubmit(values)}>
Login
</button>
</div>
</div>
</div>
</StyledLogin>
</>
)
}
export default Login;
We have a controlled form for the email and password entry as well as an alert which appears during a login error.
When we login, we will call the passed in login function and either set an error message to be shown or navigate to our desired url.
The StyleLogin component is shown below
import styled from 'styled-components'
export const StyledLogin = styled.div`
.wrapper {
background-color: #444;
color: #333;
border-radius: 10px;
margin: 0 auto;
margin-top: 200px;
padding: 20px;
overflow: hidden;
position: relative;
width: 300px;
.alert {
border-radius: 0.25rem;
border: 1px solid transparent;
margin-bottom: 1rem;
padding: 0.75rem 1.25rem;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1rem;
font-weight: 400;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.form {
position: relative;
margin: 0 auto;
margin-top: 20px;
width: 90%;
input {
background: white;
border-radius: 5px;
border: 1px solid #eee;
color: #000;
font-size: 14px;
font-weight: 400;
height: 25px;
padding: 8px;
transition: all 0.3s ease 0s;
width: 93%;
border-radius: 0;
&:focus {
box-shadow: 0 0 5px rgba(81, 203, 238, 1);
border: 1px solid rgba(81, 203, 238, 1);
outline: 0;
}
}
.logo {
margin-bottom: 20px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: baseline;
button {
outline: 0;
cursor: pointer;
padding: 9px 18px;
font-size: 13px;
line-height: 1.25rem;
border-radius: 4px;
user-select: none;
border: 1px solid transparent;
font-weight: 700;
text-align: center;
vertical-align: middle;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
}
}
}
`
Routing
Now that we have our components set up, we can build our app routing.
At the top we have the client.tsx
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
import ReactDOM from 'react-dom/client'
import * as config from "../config";
import AppRoutes from "./AppRoutes";
const client = new ApolloClient({
uri: config.api.uri,
cache: new InMemoryCache()
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<ApolloProvider client={client}>
<AppRoutes/>
</ApolloProvider>
)
We wrap our app in the ApolloProvider to enable us to use hooks like useMutation and useQuery (which were used in our userContext).
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import DashboardPage from "./pages/dashboard";
import Error404 from "./pages/error404";
import HomePage from "./pages/home";
import LoginPage from "./pages/login";
const AppRoutes = () => (
<>
<Router>
<Routes>
<Route path="/" element={<HomePage/>}/>
<Route path="/dashboard" element={<DashboardPage/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="*" element={<Error404/>}/>
</Routes>
</Router>
</>
)
export default AppRoutes;
Our AppRoutes have four pages for Home, Dashboard, Login and a 404.
404 and Home are pretty boring
const Page = () => (
<div className="home">
<h1>Home</h1>
<ul>
<li><a href="/dashboard">Go to Dashboard</a></li>
</ul>
</div>
)
export default Page;
const Page = () => (
<div className="error404">
<h1>Error404</h1>
</div>
)
export default Page;
Dashboard and Login wrap our layout components in the UserContext to allow those child components to access our user state.
import { isBrowser } from "@contentpi/lib";
import { FC } from "react";
import LoginLayout from "../components/users/LoginLayout";
import UserProvider from "../contexts/user";
interface IProps {
currentUrl?: string
}
const Page: FC<IProps> = ({currentUrl = isBrowser() ? window.location.search.replace('?redirectTo=', '') : ''}) => (
<UserProvider page="login">
<LoginLayout currentUrl={currentUrl}/>
</UserProvider>
)
export default Page;
import DashboardLayout from "../components/dashboard/DashboardLayout";
import UserProvider from "../contexts/user";
const Page = () => (
<UserProvider page="dashboard">
<DashboardLayout/>
</UserProvider>
)
export default Page;
And thats it! We now have a fully functional, production grade application — simple as it may be. You can use this slice as a reference to build out more SQL Tables, APIs, React Contexts, Routes and Components to flesh out your application.
Summary
- We can create contexts and pass down exported functionality and values by wrapping the children component in the provider’s HOC
- We can use react-router-dom to give our single page web app routes for navigation
- We can use the ApolloProvider to configure and preform GraphQL queries from our contexts and components.
Conclusion
This example project should give you an end to end understanding of how to create a production grade React app. We utilized postgres and GraphQL to create a user management system and then utilized everything we have learned thus far: Contexts, React routes, styled components and different component composition patterns to build the front end which interacts with our backend.