With 12 years of professional experience in front-end development, I have used 8 different frameworks in production. By any standards, that's a lot. While at the beginning of my career it was fun and exciting to learn something new and discover different flavors and concepts from various frameworks, now I am seeking a bulletproof solution that simply works.
The ideal architecture has a low cognitive load, is easy to maintain, scalable with the application's growth, and is customizable to accommodate the imagination of any UX designer. Unfortunately, such I couldn’t find a framework that meets all those needs, and, no, I am not on a mission to build one.
However, we do have many tools at our disposal. Can't we simply wire them all together? The short answer is no.
If you choose to wire different libraries yourself, various problems arise, such as overlapping functionality and different assumptions in their usage. It is frustrating to piece together different libraries in the React ecosystem.
Fortunately, I believe we have found a sane architecture that we are happy with.
Before we dive into the details, let's establish our requirements:
Clear Separation of Concerns Without Side Effects: As developers, we crave clarity, a map that shows us exactly where to lay down our code.
Scalability: Our architectural design should gracefully expand as our application grows, whether it's composed of 2 or 142 components.
Testability: Each layer should be testable in isolation, allowing for easy swaps if needed.
Simplicity: Our architecture should possess a low cognitive load, drawing from familiar concepts. This simplicity should make learning, maintenance, and modifications a breeze.
Implementation Blueprint
1. Project build foundation
The popular create-react-app is deprecated. Other recommended tools like Next.js or Remix have many assumptions and conventions that are imposed on developers. While following the recommended flow with these tools can make the development process a pure joy, it requires a strong commitment that we don't want to make from the beginning.
Instead, we have chosen Vite with the React TypeScript template. Vite is a fast, non-opinionated build library that provides the necessary structure for our application without imposing any strict conventions. It is also much easier to configure compared to Webpack, and can be easily extended if necessary.
2. Routing
Since we don't follow the conventions, we will handle the routing ourselves. In the React ecosystem, there is only one library for that - React Router. Specifically, we will use react-router-dom v6.
However, we will not be using loader functions and actions as recommended. They impose more limitations on client-side applications - you cannot use react hooks inside the loader function, they control the flow of fetch and render with function calls and the <Suspense /> component, among other things.
Using these functions makes more sense if you are using Remix, the full-stack framework written by the same authors as React Router. It is very powerful in that context.
3. Authentication
Auth0 is a popular user management tool with a great developer experience library for React. It simplifies login configuration, user management, and more.
Auth0 also offers a powerful binding of their JavaScript library for React with the use of hooks.
To start, we need to configure the Auth0 provider, which is a straightforward process.
When we want certain routes to be accessible only for logged-in users, Auth0 provides a useful higher-order component. We can wrap it in a Protected component and use it later in the router configuration as follows:
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
index: true,
element: <Main />,
},
{
path: 'protected',
children: [
{
index: true,
element: <Protected component={Internal} />,
},
],
},
],
},
]);
The Protected component is very simple too:
import { withAuthenticationRequired } from '@auth0/auth0-react';
export const Protected = ({
component,
...args
}: {
component: React.ComponentType;
}) => {
const Component = withAuthenticationRequired(component, args);
return <Component />;
};
When navigating to a specific route, auth0 checks if the user is logged in before rendering the component. If the user is not logged in, auth0 performs a full page redirect to the auth0 configuration page.
Auth0 is also used to handle the last concern, which is obtaining an access token for the logged in user. We can use this access token in the Authorization header when making API calls to our server.
Once the user is logged in, we extract the access token and store it for future use.
4. State management
When your application grows, you will eventually need a way to manage fetched data from a server and the application state.
There are many tools available for this purpose.
We have chosen Zustand, a small and simple library based on Flux concepts. I won't go into much detail comparing it to other libraries, as there is a dedicated page for that here.
I would like to specify the factors that were important to us in making this choice:
Small API surface: Zustand essentially has only two concepts - store and actions. This means there is not much boilerplate code that developers have to write, such as wiring actions or reducers.
Simplicity: It provides a hook and actions that mutate the initial state. That's it. There are no extra magical features like in Mobx.
The store is a React hook and can also be accessed as a plain JavaScript object if needed.
Easy scalability: As your application grows, you can split the store into separate slices with dedicated actions.
5. API calls
The final layer of our client-side architecture is the API. This is where we make calls to our server. The API layer interacts exclusively with the store (a vanilla object) and its actions. It is important to avoid any direct calls from components to maintain a clear separation between boundaries.
The API layer should also be stateless and focus on providing minimal logical data manipulation. Data management should be handled by the data management layer.
For making API calls, we create an Axios instance with an interceptor that takes an access token and adds it to every request sent to the server.
import axios from 'axios';
import { useStore } from '../store';
const axiosInstance = axios.create({
baseURL: `${import.meta.env.VITE_SERVER_URL}/api`,
});
axiosInstance.interceptors.request.use(
async (config) => {
const token = useStore.getState().me.token;
config.headers['Authorization'] = `Bearer ${token}`;
return config;
},
(error) => {
return Promise.reject(error);
},
);
export default axiosInstance;
Getting started
So, what have we achieved? We have created a layered client-side architecture for a scalable project with clear boundaries. Each layer is separately testable.
To get started, you can create a new GitHub repository using the sane-front-end-project-template. It already includes all the mentioned components wired together.
Comments