Deprecation Warning: This project has been of immense value to discover best practices around React, with stacks like Remix becoming the way of building React apps, which offers consolidation of many of the plugins in use and cutting down a lot of edge case handling, it makes sense for us to move to a framework like Remix.
Remix offers a full stack approach and takes advantage of Server Side Rendering, improving performance of the client side applications. We still believe in the value of an API and decoupling the front end from the server side logic. The approach allows us to move away from Remix if it appeared to be the right decision to make or support native mobile clients for our applications.
The result is the psychedelic-stack which maintains our Python foundation on the server and uses Remix in a Backend for your Frontend approach.
Most of what have learnt by building this client will be migrated or ported as appropriate.
Objectives:
- Typescript based Create React App base project
- Validated SSL without any other dependencies
- Internationalization support
- Tailwind CSS based theming support
- Header
<head>
management for usability using Helmet - Meeting W3C AAA Accessibility
- Proxy API from Docker container without any other dependencies
- Establish a pattern for monorepos for applications with multiple modules.
- End-to-end testing using Microsoft Playwright
-
API calls with autorest-typescript or openapi-typescript-codegen - react-query based caching, using react-router
v6.4.x
and orval based API code generation (plenty documentation available in theREADME
) - Storybook to prototype components
Standard Libraries and UI components:
- Page routing with React Router, a industry leading Router components (this is not an issue for GatsbyJS or NextJS as they ship their own)
- Loading skeletons
- DatePicker component, Airbnb makes available their date component
- Rich/Markdown editor
-
React Hook Formsprobably no longer required in favour of native validation - React Drop Zone for file upload
- Research and recommend animation libraries, e.g Framer Motion
- React Phone number input
- Date Time parsing
- Addition hooks from rooks
- Headless UI, from the makers of Tailwind CSS
As a general rule of thumb, we want to avoid tooling in anything we don't need to. Unless absolutely necessary we use yarn to run any scripts e.g generating OpenAPI clients.
The React app is configured to run with a signed SSL certificate and proxy the FastAPI application running in the development container.
Once setup you can run the development servers via:
yarn start
React uses the
HTTPS
environment variable to run run in secure modeHTTPS=true yarn start
, we make a more permanent change inpackage.json
to save us having to do this manually.
The intent here is run SSL (which is as close to development as possible) but without needing to introduce yet another tool (e.g a reverse proxy).
mkcert allows us to provision a SSL certificate for our development environment.
Install mkcert via brew
:
$ brew install mkcert
Install nss (this is only needed if you use Firefox)
$ brew install nss
Setup mkcert on your machine (this register it as a certificate authority)
$ mkcert -install
Next we modify the start
script to use SSL, this saves us having to set the HTTPS
environment variable manually:
"scripts": {
"start": "HTTPS=true react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
Create .cert directory if it doesn't exist
mkdir -p .cert
Generate the certificate (ran from the root of this project)
mkcert -key-file ./.cert/key.pem -cert-file ./.cert/cert.pem "localhost"
Lastly update package.json
so the start
script can use the generated certificate:
"scripts": {
"start": "HTTPS=true SSL_CRT_FILE=./.cert/cert.pem SSL_KEY_FILE=./.cert/key.pem react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
Amongst other things we add the src
folder to the include
section so that the TypeScript compiler can find our source files.
{
"compilerOptions": {
},
"include": [
"src"
]
}
the imports read from the top of the src
folder e.g:
import { UserTypeRequest } from 'api/models';
Our intention is to keep things simple and not introduce tools unless it's absolutely necessary. CRA provides a guide to proxying APIs calls. There's a simple setup and then a more complex one that uses http-proxy-middleware.
We need to use the later for so we can configure stripping the prefix from the API calls. This is necessary because of the way our FastAPI application is configured in the docker container. You can read about this in the server labs.
In principle what we are doing is stripping the /api
prefix from the calls i.e /api/ext/echo
is proxied to /ext/echo
on http://localhost:8000
.
http-proxy-middleware
provides a pathRewrite
option to achieve this, this is detailed in their documentation, the following is an example that works for us:
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'http://localhost:8000/',
changeOrigin: true,
pathRewrite: {'^/api' : ''} // Removes the prefix so the container responds properly
})
);
};
Most React developers start off using a Router
library and in the more recent times use a Context
to store the state of the user interface and in many instances the asynchronous data (i.e from a remote source, typically an API). As we trying and refactor the application code to separate user interface elements from data state, we often end up with issues like Deduping multiple requests (where the same request end point is called multiple times, mostly due to the state being re-initialised on paints).
Until recently these have been three separate topics. While React does not have an opinion on a solution for this, recent developments like React Router introducing data loading in v6.4
has had us revisit all three components in unison.
React Router is about when, data caching libs are about what. - Ryan Florence
As we apply our templates to larger problems, we must now think of the most efficient patterns to handle these complex use cases. To this effect we:
- Separate application UI state and asynchronous data state
- Use
react-query
to handle caching, deduping, reflection of data - Use
react-router
andreact-query
in harmony - Use
orval
to generate the api client that can be used byreact-query
React Router 6.4 changes the way it's configured and introduces data loading, the two major concepts to take note of are:
- Loaders, which are async functions fired before the route is rendered
- Actions, which are async function fired from a
<Form>
This is in attempt to provide a HTML + HTTP like with react-router
doing the async work.
A loader
or an action
can throw an exception which signals the Router
to load the errorElement
, which is meant to be a fallback components for error states.
The following is a code extract to how react-router
is configured with react-query
, it will become clear how we are using the two libraries together.
First of all note that the router is created using the createBrowserRouter method which uses the DOM History API. Children are passed as an array to the routes, and it's behaviour is that of when we nested <Route>
and used an <Outlet>
in the parent container.
import {
RouterProvider,
createBrowserRouter,
} from "react-router-dom";
import {
QueryClient,
QueryClientProvider,
} from 'react-query'
import { ReactQueryDevtools } from 'react-query/devtools'
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
},
{
path: "/auth",
element: <Authentication />,
children: [
{
path: "login",
element: <Login />,
},
{
path: "otp",
element: <OTP/>
}
]
}
]);
const queryClient = new QueryClient();
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</QueryClientProvider>
</React.StrictMode>
);
The QueryClientProvider
wraps the entire application. <ReactQueryDevtools>
injects the developer tool into the application for react-query
and it stripped away in production builds.
The queryClient
is used to invalidate the cache, we will talk about in the next section.
errorElement
is rendered on errors like when a path isn't found, this can be provided per path. The one on the Root
is used to render an error page if a path isn't found.
Further reading:
Before we get into how we use configure and use react-query
and react-router
loaders together, lets meet Orval, the API client generator. Orval does way more than just generate a type checked API client, but for the purposes of this guide we will focus on the API client portion.
Add orval
to be globally available:
yarn global add orval
Add/modify the orval.config.js
located on the root
folder of the project:
module.exports = {
labs: {
output: {
mode: 'tags-split',
target: 'src/api/labs.ts',
schemas: 'src/api/models',
client: 'react-query',
mock: true,
},
input: {
target: './openapi.json',
},
},
};
By default we place the api
and models
in src/api
these are split into files and grouped by the OpenAPI tags
assigned on the server side. These are wrapped as two yarn
scripts:
fetch-openapi
- fetches the OpenAPI spec from the server and places it on the rootcodegen
- fetches the OpenAPI sepc and generates the API client
The generated code depends on faker-js,
axiosand
msw`, install them as follows:
yarn add axios
yarn add @faker-js/faker --dev
yarn add msw --dev
If you have cloned the project from our template these should already be part of the dependencies.
The component should use the hook generated by the client and the loader should use the generated helper method
export const loader =
(queryClient) =>
async ({ params }) => {
const query = contactDetailQuery(params.contactId);
return (
queryClient.getQueryData(query) ?? (await queryClient.fetchQuery(query))
);
};
While most traditional state management libraries are great for working with client state, they are not so great at working with async or server state. This is because server state is totally different. For starters, server state:
- Is persisted remotely in a location you do not control or own
- Requires asynchronous APIs for fetching and updating
- Implies shared ownership and can be changed by other people without your knowledge
- Can potentially become "out of date" in your applications if you're not careful
Once you grasp the nature of server state in your application, even more challenges will arise as you go, for example:
- Caching... (possibly the hardest thing to do in programming)
- Deduping multiple requests for the same data into a single request
- Updating "out of date" data in the background
- Knowing when data is "out of date"
- Reflecting updates to data as quickly as possible
- Performance optimizations like pagination and lazy loading data
- Managing memory and garbage collection of server state
- Memoizing query results with structural sharing
Reference: Motivation from the
react-query
documentation.
We've all comes across these issues in React, or you eventually will. Tanner Linsley's presentation React Query: It’s Time to Break up with your "Global State"! gives an overview of these issues and how react-query
goes a long way to address them.
react-query
handles the following use cases rather well:
- Background fetching
- Parallel queries
- Window focus refetching
- Query retries
- Paginated queries
Developer tools
import { ReactQueryDevtools } from 'react-query/devtools'
Further reading:
- React Query meets React Router - how to make Router and Query work together.
To make relative links just go
<Link to={`users/${user.id}`}>Edit</Link>
Form is a wrapper provided by react-router
Questions:
- Example uses
useLoader
in the editor screen to load data, but I am usinguseGetUserById
which is anorval
thing, is this correct - Examples use
{user?.firstName}
where as I am having to do{user?.data.firstName}
, where am I going wrong?
You'll notice the names of the autogenerated methods to be meAuthMeGet
, which is not ideal for readability. This is a result of of what the Open API spec calls operationId
which is used to generate the method name. The operationId
is a unique identifier for the operation and is used to generate the method name. The operationId
is not required in the Open API spec, but it is recommended to provide one.
Our Python server lab outlines how to configure the operationId
in FastAPI, which greatly improves the readability of the generated clients. The evolved templates is able to name things based on the function names, so long as your api function names are pretty, so will your documentation 😀.
Wisdom: Write code to be read
The following are opinionated conventions for the Anomaly templates. They have been informed by UNIX based systems modern web development techniques:
Our react components are always named in PascalCase
:
export const MyComponent = () => {
return <div>MyComponent</div>;
};
The file containing the component MyComponent
must be same as the component but in kebab-case
i.e. my-component.tsx
.
When instantiating a component the instance must be assigned to a variable name in camelCase
macOS by default uses a type insensitive file system, using CamelCase for file names can cause issues on other platforms. This will be masked if you build on your local desktop but will come to light if you are using a CI/CD pipeline.
Even thought your application may never be used across regions (highly unlikely if you are building a global product), it's still a good idea to internationalize your application. This is because it's a good practice to get into and it's a good way to learn about the tools and techniques. It is also easier to internationalize your application from the start than to do it later.
Things to consider are:
- The interfaces lay themselves towards right-to-left languages
- Ability to provide multiple locales (even English varies between countries like USA and the UK)
There are several providers like Locaize that help with the translation services.
We use the react-i18next library for internationalization. The following steps are taken to set up the library:
yarn add i18next react-i18next --save
additionally you can add i18next-browser-languagedetector
for automatic language detection (this is up to your use case, if you want the user to choose their language or inherit from their browser settings).
yarn add i18next-browser-languagedetector
Namespaces are react-i18n
way of allowing projects to have translations split into multiple files.
Our setup is loosely based on the react-typescript example with multiple namespaces
.
Conventions for our file and folder structure is as follows:
- group namespaces into languages named by the language code e.g
en
or more specificallyen-AU
- each namespace should be a file named by the namespace e.g
common.json
ordashboard.json
that relates to a particular part of your application. - Each language must provide every
namespace
declared by the application - The set of master translations should be in the primary language of the application e.g
en-AU
oren-US
and the others follow - The
i18n/config.ts
is the configuration file for the internationalisation library. This is what's imported inindex.ts
The @types
folder has the type definitions requires for the internationalisation to work with TypeScript.
Trnaslation files are JSON
files (placed in i18n/language_code/namespace.json
):
{
"title": {
"welcome": "Welcome to {{site_name}}"
}
}
Note that we have nested keys, this is to allow for more complex translations. The {{site_name}}
is a placeholder for the value of site_name
which is passed in as a parameter to the t
function.
Once this is setup you can use the useTranslation
hook to access the translations in your components (notice the site_name
variable being provided a dynamic value).
import { Helmet } from 'react-helmet';
import {
useTranslation
} from "react-i18next";
function App() {
// Internationalisation
// @ts-ignore
const { t } = useTranslation();
return (
<div className="flex flex-col justify-center w-screen h-screen font-bold text-black app">
<Helmet>
<title>
{t('title.welcome', {
site_name: "Labs"
})}
</title>
<meta name="description" content="Welcome to Anomaly Labs" />
</Helmet>
</div>
);
}
export default App;
You don not need to do this as the project is already configured for internationalization, these are just notes for reference.
The following is an evolving conversation. While there's no official pattern for folder structures, what we have obtained from reading various articles from experienced developers is that project structures are an evolving idea, in general we follow these principles:
src/components
, contain components that are truly reused around the project, if there's a particular component tied to a view then it makes more sense to keep it within the view folder.src/views
, contains views for the application. These should be grouped by function e.g authentication, dashboard.
For many instances we serve that built web client via an S3 compatible object store. Most infrastructure providers allow serving files from an S3 compatible object store (see the following section for details), however in simpler use cases you use a Docker container to serve the static files.
Refer to the Dockerfile
on this repository to see how we setup a two phase build that:
- Sets up an environment with the
package.json
andyarn.lock
file using theNodeJS
image - Builds the
react
application - Builds an
nginx
image and copies the builtReact
app to the image
Note: we're literally using the default
nginx
configuration to serve the files
To test the setup, first build the image using:
docker build -t lab-web-client -f Dockerfile .
you can test the image by using the built image:
docker run -p 8080:80 lab-web-client
which will expose port 80
from the image to port 8080
on your local machine. You can then visit http://localhost:8080
to see the application. When building for deployment on x86/64
architecture you must specify the platform --platform=linux/amd64
via the flag.
These remarks are made around Linode's implementation of an object store which is S3 compatible. These are hopefully translatable across any S3 compatible service.
Object store bucket names are unique in a region, so it's best to come up with a pattern (e.g FQDN) so we never conflict with users around world.
This should ideally be done as part of a CI/CD process, but the commands are nice to have for reference and are handy for development builds. We encourage that you keep the bucket related secrets in an .env
file, this translates directly into storing this a CI/CD environment.
Note: the keys are secrets and should never be versioned, it's also advisable to cycle these keys from time to time.
ACCESS_KEY=LEGG.....
SECRET_KEY=qUuXn.....
BUCKET_FQDN=lab-web-client.ap-south-1.linodeobjects.com
BUCKET_NAME=lab-web-client
BUCKET_REGION_FQDN=ap-south-1.linodeobjects.com
You will need to prepare the bucket to serve static files, the s3cmd
can help you with this:
export $(cat .env) && s3cmd
--access_key=$ACCESS_KEY
--secret_key=$SECRET_KEY
--host=$BUCKET_REGION_FQDN
--host-bucket=$BUCKET_FQDN
ws-create
--ws-index=index.html
--ws-error=index.html
s3://$BUCKET_NAME/
This is a one time process, following this step the bucket should be ready to serve files.
Once you've built client you can use a tool like s3cmd
to synchronise the files to the S3 bucket.
export $(cat .env) && s3cmd
--access_key=$ACCESS_KEY
--secret_key=$SECRET_KEY
--host=$BUCKET_REGION_FQDN
--host-bucket=$BUCKET_FQDN
sync
--no-mime-magic
--delete-removed
--delete-after
--acl-public
build/*
s3://$BUCKET_NAME/
Buckets are also required to be empty before you can drop them from the provider:
export $(cat .env) && s3cmd
--access_key=$ACCESS_KEY
--secret_key=$SECRET_KEY
--host=$BUCKET_REGION_FQDN
--host-bucket=$BUCKET_FQDN
del
--recusrive
--force
s3://$BUCKET_NAME/
If you are using an alternate Object Store like one provided by Linode, then you might need to provide additional context by changing the suffix values of the buckets. For example Linode buckets are suffixed with linodeobjects.com
and the region is suffixed with the region name.
Create a file called .s3cfg
in your home directory with the following values:
website_endpoint=http://%(bucket)s.website-ap-south-1.linodeobjects.com
this will result in the cli
outputting the correct URL for the bucket.
Bucket s3://lab-web-client/: Website configuration
Website endpoint: http://lab-web-client.website-ap-south-1.linodeobjects.com
Index document: index.html
Error document: index.html
Note: while the bucket's FQDN is
lab-web-client.ap-south-1.linodeobjects.com
to serve the web site you must use the generated address oflab-web-client.website-ap-south-1.linodeobjects.com
. The FQDN is used to serve the context of the bucket not a site.
We use Microsoft Playwright for end-to-end and API testing, including the use of Github actions to automate testing on pull requests. While we don't intend to replicate all the documentation here, the intent is to maintain information about the portions of the tool that we use and how we think it's best configured.
To configure Playwright for the project use:
yarn create playwright
Follow the prompts to complete installation, we generally use the default values
To run the headless tests use:
npx playwright test
and to view reports (this will end up running a web server with with the reports displayed as a site):
npx playwright show-report
npx playwright codegen playwright.dev
Anomaly primarily uses Visual Studio Code as the standard code editor. Here are some useful plugins that has proven useful for our development team:
- GitHub co-pilot - AI assisted code completion
- GitLess - supercharged Git workflows
- Headwind - An opinionated class sorter for Tailwind CSS, ensures that every developers classes are in the same order
Articles:
Contents of this repository are licensed under the Apache 2.0 license.