React does not need any introduction at all, and here I am not going to include a description of what and how it works. I tried to cover in more detail the main aspects of the React application architecture, which in the future will help you build an easily scalable project very quickly. We have developed exactly the architectural part of building applications using React Router v6.
Also, I am going to use styled-components
and at the end of the article, you can get the GitHub link to the repository.
Before starting any programming manipulations, it is better to accurately determine all routes in the application that will be involved. The research is better with some diagrams plan where you can see the whole general picture of your application.
Also, it’s important to be clear about which routes have to be protected. Protection means when the user has access to a specific route only if he has authorization. If you are not authorized, other words are not logged in, then you can’t have that access to that route.
And also a very important part of redirections. For example, if you have the authorization, what has to happen if you will go to the home route? Should you be redirected and where? All of those redirection rules are to be identified at the beginning. Let’s take a look at the diagram with the application routing schema.
Initially, it’s important to divide protected and public routes. Here the green color means public and red is protected. Also, you can find some green boxes with protected paths, which means those protected routes will be with a different logic of redirection.
Redirection has to happen to the first step if you came to the second step of registration without providing dates on the first step.
Navigation is something common among all routes but depends on the session
. The stack of links will be different if the user got authorization. In that way, all routes are better to wrap up to MainLayout
where you gonna have the logic of lifecycle and session injection, and, of course, what to show up in header navigation. It’s important to understand the state depends on the authorization.
In our case, all View
components except NotFound
and Navigation
component depends on the session
. That means there is going to show Loader
before exposing the component in order to be clear on what to render. The folder structure contains a bunch of components, constants, and fakes for making API calls.
application-architecture-boilerplate
├── package.json
└── src
├── App.js
├── __mocks__
│ └── db.mock.js
├── components
│ ├── Footer.js
│ ├── Loading.js
│ ├── MainLayout.js
│ ├── Navigation.js
│ ├── ProtectedRoute.js
│ └── PublicRoute.js
├── constants
│ └── index.js
├── fake
│ ├── fakeApi.js
│ └── fakeCache.js
├── hooks
│ └── useSession.js
└── index.js
As you can see that we going to use a fake API for our application. In reality, it can be an Apollo Client or REST API call, but in our case, we going to use a Promise
with Timeout
for 1s
request delay. We have to take care of our authentication for login and logout and request the current session from a fake API. Eventually, it’s a simple class with amount useful methods.
import { db } from 'src/__mocks__/db.mock';
import { fakeCache } from './fakeCache';
class FakeApi {
static REQUEST_TIME = 1000;
static KEY_CACHE = 'cache';
static DB = db;
context = {};
constructor() {
const context = fakeCache.getItem(FakeApi.KEY_CACHE);
if (context) {
this.context = context;
}
}
static userById = id => FakeApi.DB.find(user => user.id === id);
#asyncRequest = callback =>
new Promise(resolve => {
setTimeout(() => {
const result = callback();
resolve(result);
}, FakeApi.REQUEST_TIME);
});
getSession() {
return this.#asyncRequest(() => this.context.session);
}
login() {
this.context.session = FakeApi.userById(1);
fakeCache.setItem(FakeApi.KEY_CACHE, this.context);
return this.getSession();
}
logout() {
this.context = {};
fakeCache.clear();
return this.#asyncRequest(() => null);
}
}
export const fakeApi = new FakeApi();
You can find out that in the constructor
we are using cache. It’s because our request has to have a cache for responses and use the cache as an advance for the next requests to improve performance. This implementation is quite crude and simple, but it’s easy to get the gist of it.
The flow is, that once we call, we have to create a session, and logout
clears the session and cache as well. Each asyncRequest
should have an REQUEST_TIME
as fake delay for our request. But what about the cache?
export const fakeCache = {
getItem(key) {
return JSON.parse(localStorage.getItem(key));
},
setItem(key, value) {
localStorage.setItem(key, JSON.stringify(value));
},
clear() {
localStorage.clear();
},
};
For the storing / caching data, we going to use the localStorage
. This is just a simple object with methods, nothing else.
The router part has to care our Private
and Public
routes. Redirections have to happen from Private
when we trying to access /login
and when if the user goes to some private route without a session, it has to have the redirection to /login
.
"/" // -> Home view depends on session
"/about" // -> About show with session and without
"/login" // -> Login show with without session only
"/signup" // -> Sign Up show without session only
"/forgot-password" // -> Forgot Password show without session only
"/account" // -> User show with session only
"/settings" // -> Settings show with session only
"/posts" // -> Posts and nested routes show with session only
"*" // -> Not Found show without dependencies at all
You can see the comment against each route described the behavior of accessibility. What’s the reason to be in SignUp
if you have already a session? I’ve seen many times that issue in other projects. So, in our case, we going to have a redirection and from ProtectedRoute
and from PublicRoute
. Only NotFoundView
should have full access in the end.
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<HomeView />} />
<Route path="/about" element={<AboutView />} />
<Route
path="/login"
element={<PublicRoute element={<LoginView />} />}
/>
<Route
path="/signup"
element={<PublicRoute element={<SignUpView />} />}
/>
<Route
path="/forgot-password"
element={<PublicRoute element={<ForgotPasswordView />} />}
/>
<Route
path="/account"
element={
<ProtectedRoute
element={<ProtectedRoute element={<UserView />} />}
/>
}
/>
<Route
path="/settings"
element={<ProtectedRoute element={<SettingsView />} />}
/>
<Route path="/posts" element={<ProtectedRoute />}>
<Route index element={<PostsView />} />
<Route path=":uuid" element={<PostView />} />
</Route>
</Route>
<Route path="*" element={<NotFoundView />} />
</Routes>
As you can see that we added protection for both flows. ProtectedRoute
going to navigate to /login
in the case when no session and PublicRoute
will redirect to /
, because HomeView
has to check for authorization.
const HomeView = () => {
const session = useOutletContext();
return session.data ? <ListsPostsView /> : <LandingView />;
};
The session
possible to get the right form useOutletContext()
it’s because MainLayout
will provide that context
.
Everything is wrapped in MainLayout
which going to provide the context
of the session
and other global-related data. For MainLayout
we going to use the common Route
and under Outlet
will expose all routes. Let’s take a look at the setup.
const MainLayout = ({ navigate }) => {
const session = useSession();
return (
<StyledMainLayout>
{!session.loading ? (
<div>
<Navigation session={session} navigate={navigate} />
<StyledContainer isLoggedIn={!!session.data}>
<Outlet context={session} />
</StyledContainer>
</div>
) : (
<Loading />
)}
<Footer />
</StyledMainLayout>
);
};
The Footer
has no dependencies of state and we going to render it all the time. But Navigation
and all nested routes have to have access the session
. Here is the Outlet
which render the child route elements and where to pass the context
to all children.
The request which provides us with the session
data has a delayed response and in that case, we show the Loading
component.
When the application is mounted, we must request the current session
. The useSession
hook will fire on the mount and get the session
either from the cache or from the API.
export const useSession = () => {
const cache = fakeCache.getItem(SESSION_KEY);
const [data, setData] = useState(cache);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!cache) {
setLoading(true);
}
fakeApi
.getSession()
.then(session => {
if (session) {
setData(session);
fakeCache.setItem(SESSION_KEY, session);
} else {
setData(null);
fakeCache.clear();
}
})
.finally(() => {
setLoading(false);
});
}, []);
return { data, setData, loading };
};
Every FakeApi
request we are storing the response to cache, like cookies stored in real websites. Then it’s time to show depends on session
the Navigation
with Login
/ Logout
and child components.
The state for Navigation
is important to enable or disable buttons during the current request. If to hit Logout
better to disable all buttons in order to prevent other operations during session clearance.
export const Navigation = ({ navigate, session }) => {
const [isLoading, setLoading] = useState(false);
const onLogin = async () => {
setLoading(true);
const sessionData = await fakeApi.login();
session.setData(sessionData);
fakeCache.setItem(SESSION_KEY, sessionData);
setLoading(false);
navigate('/');
};
const onLogout = async () => {
setLoading(true);
fakeCache.clear();
await fakeApi.logout();
session.setData(null);
setLoading(false);
navigate('/');
};
return (
<StyledNavigation>
<Link to="/">Home</Link>
{session.data ? (
<div>
<Link to="/posts">Posts</Link>
<Link to="/settings">Settings</Link>
<Link to="/account">My Profile</Link>
<button disabled={isLoading} onClick={onLogout}>
{isLoading ? 'loading...' : 'Logout'}
</button>
</div>
) : (
<div>
<Link to="/about">About</Link>
<Link to="/signup">Sign Up</Link>
<button disabled={isLoading} onClick={onLogin}>
{isLoading ? 'loading...' : 'Login'}
</button>
</div>
)}
</StyledNavigation>
);
};
It is clear that the routing should be protected depending on the session
, but how do get the context
in each Route Component? We will still come to the aid of the context useOutletContext()
provided by react-router
API.
export const ProtectedRoute = ({ element }) => {
const session = useOutletContext();
return session.data ? (
element || <Outlet />
) : (
<Navigate to="/login" replace />
);
};
For the PublicRoute
everything is almost the same but another way around with a different redirection route.
export const PublicRoute = ({ element }) => {
const session = useOutletContext();
return session.data ? <Navigate to="/" replace /> : element || <Outlet />;
};
Possibly you can see, that better to have something like a SmartRoute
where is better to provide only the route of redirection and prop to identify is it a public
or private
. I prefer to separate such logic for future scalability. And this is pretty much that’s it.
React Router is the most popular routing solution for React applications and provides the most obvious and clear API for developers today. Since amazing updates in the last version building a routing architecture has become much easier and more convenient. Hope this structure of the application aims to help quickly to build up the body of future React projects. Thank you.
GitHub Repository: architecture-application-boilerplate