Within this chapter we go through routing stuff and create the page layout base.
First, we should create two more aliases:
A @components
alias for the new src/component/
folder where our general app specific components live in,
and a @pages
alias for the new src/pages
folder where our page components will be defined.
The procedure is the same as described in 2.7 Ramp up our import paths
.
I am sure you can manage to get that done on your own 😎
To query some general configurations of the app I recommend centralizing those in a separate context. For the next step of our tutorial it is required to have access to the company name of our app. The procedure is the same as we've done for our current user context. So let's add a new config context with following files.
// src/packages/core/config/config.ts
import { createContext, useContext } from 'react';
export type Config = {
companyName: string;
};
const configContext = createContext<Config | null>(null);
export const ConfigProvider = configContext.Provider;
export function useConfig(): Config {
const config = useContext(configContext);
if (!config) {
throw new Error(`no config was provided`);
}
return config;
}
and as usual, with the export from an index file:
// src/packages/core/config/index.ts
export * from './config';
However, this context needs to be integrated as well.
Add a new context reference to src/ServiceProvider.tsx
and src/TestServiceProvider.tsx
.
This can be done with the React.useRef
hook.
This makes sure, that the context is initialized only at the first render of the component.
import { Config, ConfigProvider } from '@packages/core/config';
Then add this lines before the return statement of the provider:
const configRef = useRef<Config>({
companyName: 'ACME',
});
Finally, like for the current user, we need to provide the config by wrapping the <App>
with the <ConfigProvider>
:
return (
<ConfigProvider value={configRef.current}>
// previous returned stuff...
</ConfigProvider>
);
Well done! We are going to use this context later in this chapter.
Every browser app with multiple pages requires some routing mechanism. In case of React the most common library to have in mind is react-router. We could build our own routing logic, but I think for such a common task it absolutely makes sense to take a proven solution which goes hand in hand with the browser history. Nice to know: react-router does also provide a router for react-native.
Install it with:
npm install react-router-dom --save
We are going to use the <Link>
component of it in the next step.
Before we are going to create a page component, we need a reusable page layout.
This ensures that we don't have to write the same logic for every single page over and over again.
I think a new BlankPage
component should build the base for every page layout.
// src/components/page-layout/BlankPage.tsx
import React, { FC, ReactNode, useEffect } from 'react';
import { useConfig } from '@packages/core/config';
export type BlankPageProps = {
title: string;
children?: ReactNode;
};
export const BlankPage: FC<BlankPageProps> = (props) => {
const { companyName } = useConfig();
const titleParts: string[] = [];
if (props.title) {
titleParts.push(props.title);
}
if (companyName) {
titleParts.push(companyName);
}
useEffect(() => {
if (document) {
// only adjust this in browser environment
document.title = titleParts.join(' :: ');
}
});
return <>{props.children}</>;
};
Next, let's create a NavBarPage
component based on the BlankPage
:
// src/components/page-layout/NavBarPage.tsx
import React, { FC, MouseEvent } from 'react';
import { BlankPage, BlankPageProps } from './BlankPage';
import { anonymousAuthUser, useCurrentUser, useCurrentUserRepository } from '@packages/core/auth';
import { Link, useNavigate } from 'react-router-dom';
function Nav() {
const navigate = useNavigate();
const currentUserRepo = useCurrentUserRepository();
const currentUser = useCurrentUser();
const isLoggedIn = currentUser.type === 'authenticated';
const currentUserDisplayName = currentUser.type === 'authenticated' ? currentUser.data.username : 'Anonymous';
function loginUser(event: MouseEvent<HTMLAnchorElement>) {
event.preventDefault();
currentUserRepo.setCurrentUser({
type: 'authenticated',
apiKey: 'foo',
data: {
id: 'foo',
username: 'Linus',
},
});
}
function logoutUser(event: MouseEvent<HTMLAnchorElement>) {
event.preventDefault();
currentUserRepo.setCurrentUser(anonymousAuthUser);
navigate('/');
}
return (
<div
style={{
marginLeft: 'auto',
marginRight: 'auto',
width: '600px',
textAlign: 'center',
}}>
<Link to="/">Home</Link> –{' '}
{!isLoggedIn && (
<>
<Link to="/auth/register">Register</Link> –{' '}
<a href="#" onClick={loginUser}>
Login
</a>
</>
)}
{isLoggedIn && (
<a href="#" onClick={logoutUser}>
Logout
</a>
)}{' '}
:: {isLoggedIn && <Link to="/user-management/my-settings">{currentUserDisplayName}</Link>}
{!isLoggedIn && currentUserDisplayName}
</div>
);
}
export type NavBarPageProps = BlankPageProps;
export const NavBarPage: FC<NavBarPageProps> = (props) => {
return (
<BlankPage title={props.title}>
<Nav />
{props.children}
</BlankPage>
);
};
and the export file:
// src/components/page-layout/index.ts
export * from './BlankPage';
export * from './NavBarPage';
Before we create some routes we need to prepare route targets. So let's create the page components of this tutorial.
The index page:
// src/pages/IndexPage.tsx
import { FC } from 'react';
import { NavBarPage } from '@components/page-layout';
export const IndexPage: FC = () => {
return <NavBarPage title="Home">Home.</NavBarPage>;
};
The RegisterPage
, within we are going to build the user registration form:
// src/pages/auth/RegisterPage.tsx
import { FC } from 'react';
import { NavBarPage } from '@components/page-layout';
export const RegisterPage: FC = () => {
return <NavBarPage title="Register">Register.</NavBarPage>;
};
A MySettingsPage
to demonstrate what happens if the user logs out while being on this page:
// src/pages/user-management/MySettingsPage.tsx
import { FC } from 'react';
import { NavBarPage } from '@components/page-layout';
export const MySettingsPage: FC = () => {
return <NavBarPage title="My settings">My settings.</NavBarPage>;
};
And finally, a NotFoundPage
. Keep in mind that the server only sends the index.html
and the bundled app in form of a .js
file, so the routing happens on client side!
However, you won't be able to send a 404 status code from the server side,
until you do or at least a server side check before sending the http header to the client.
There are various ways to solve this, but this requires a server side script, which we do not cover in this tutorial.
// src/pages/NotFoundPage.tsx
import { FC } from 'react';
import { NavBarPage } from '@components/page-layout';
export const NotFoundPage: FC = () => {
return <NavBarPage title="Not Found">Not Found.</NavBarPage>;
};
We still have no routing entry point. As we've learned in the chapters before, not only we have to find a way to support routing in the browser but also for the testing environment. So let's implement the specific routing in the right parts of our code.
Let's first wrap our <App>
with the <BrowserRouter>
within the service provider for the browser environment:
// src/ServiceProvider.tsx
import React, { FC, PropsWithChildren, useRef, useState } from 'react';
import {
anonymousAuthUser,
AuthUser,
BrowserCurrentUserRepository,
CurrentUserProvider,
CurrentUserRepositoryProvider,
} from '@packages/core/auth';
import { Config, ConfigProvider } from '@packages/core/config';
import { BrowserRouter } from 'react-router-dom';
export const ServiceProvider: FC<PropsWithChildren<{}>> = (props) => {
const [currentUserState, setCurrentUserState] = useState<AuthUser>(anonymousAuthUser);
const browserCurrentUserRepositoryRef = useRef(new BrowserCurrentUserRepository(setCurrentUserState));
const configRef = useRef<Config>({
companyName: 'ACME',
});
return (
<BrowserRouter>
<ConfigProvider value={configRef.current}>
<CurrentUserRepositoryProvider value={browserCurrentUserRepositoryRef.current}>
<CurrentUserProvider value={currentUserState}>
{props.children}
</CurrentUserProvider>
</CurrentUserRepositoryProvider>
</ConfigProvider>
</BrowserRouter>
);
};
The same applies to the testing environment's service provider but with another implementation.
Luckily react-router-dom provides the MemoryRouter
for us:
// src/TestServiceProvider.tsx
import {
anonymousAuthUser,
AuthUser,
CurrentUserProvider,
CurrentUserRepository,
CurrentUserRepositoryProvider,
} from '@packages/core/auth';
import React, { FC, PropsWithChildren, useRef } from 'react';
import { Config, ConfigProvider } from '@packages/core/config';
import { MemoryRouter } from 'react-router-dom';
class StubCurrentUserRepository implements CurrentUserRepository {
setCurrentUser(currentUser: AuthUser) {}
init() {}
}
export const TestServiceProvider: FC<PropsWithChildren<{}>> = (props) => {
const stubCurrentUserRepositoryRef = useRef(new StubCurrentUserRepository());
const configRef = useRef<Config>({
companyName: 'ACME',
});
return (
<MemoryRouter>
<ConfigProvider value={configRef.current}>
<CurrentUserRepositoryProvider value={stubCurrentUserRepositoryRef.current}>
<CurrentUserProvider value={anonymousAuthUser}>
{props.children}
</CurrentUserProvider>
</CurrentUserRepositoryProvider>
</ConfigProvider>
</MemoryRouter>
);
};
Finally, we are going to define our routes within the <App>
component. Just delete the other stuff
we have extracted to our <NavBarPage>
component, so that the App.tsx
looks like below:
// src/App.tsx
import React, { useEffect } from 'react';
import { useCurrentUser, useCurrentUserRepository } from '@packages/core/auth';
import { Route, Routes } from 'react-router-dom';
import { IndexPage } from '@pages/IndexPage';
import { RegisterPage } from '@pages/auth/RegisterPage';
import { MySettingsPage } from '@pages/user-management/MySettingsPage';
import { NotFoundPage } from '@pages/NotFoundPage';
function AppRoutes() {
const currentUser = useCurrentUser();
const isUserLoggedIn = currentUser.type === 'authenticated';
return (
<Routes>
<Route path="/" element={<IndexPage />} />
<Route path="/auth/register" element={<RegisterPage />} />
{isUserLoggedIn && <Route path="/user-management/my-settings" element={<MySettingsPage />} />}
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
function App() {
const currentUserRepo = useCurrentUserRepository();
useEffect(() => {
currentUserRepo.init();
}, [currentUserRepo]);
return <AppRoutes />;
}
export default App;
Nice one! You should now be able to go to different routes in the app.
At localhost:3000/foo you should see the NotFoundPage
.
This page should also appear, when you log in, switch to localhost:3000/user-management/my-settings,
and then do a logout.
Also execute npm run test
in your console and see if your test passes.
Otherwise, have a look at the 03-routing-1 branch
to compare with yours.
There's no need to reinvent the wheel over and over again by creating everything from scratch. Material UI (MUI) offers a huge set of components which are easy to customize and well tested. It's the preferred framework these days for most developers. It's compatibility with React makes it a perfect fit for us. Add the library and the MUI icons with:
npm install @mui/material @mui/icons-material --save
Material UI comes with the emotion library by default for adding custom css. In this tutorial, we go with styled-components instead, to gain more experience about what is going on under the hood with webpack and TS.
These two libraries are almost the same. To have a comparison, just have a look at this LogRocket article. With MUI we need to switch its styled-engine to styled-components.
Install the styled-components library and its MUI styled-engine.
npm install @mui/styled-engine-sc styled-components --save
We also need to install the TS types for it, because the styled-components does not support TS by default.
npm install @types/styled-components --save-dev
Furthermore and to make it work with Jest, we need to do a bit more than just described in the MUI documentation. Don't worry, we'll go through it together:
- Add
"@mui/styled-engine": ["./node_modules/@mui/styled-engine-sc"]
topaths
property intsconfig.json
- Add the
'@mui/styled-engine': '@mui/styled-engine-sc'
alias inconfig/webpack.config.js
- Add
"^@mui/styled-engine$": "<rootDir>/node_modules/@mui/styled-engine-sc"
inmoduleNameMapper
property ofpackage.json
Not that hard.
In order to fully install MUI, we need to provide the required fonts to the user's browser.
In general, I prefer to download the fonts from external sources
and to directly provide them to the user from my own server.
This allows us to remain independent of third-party providers.
This is also desirable from a security and availability perspective.
Nevertheless, let's include the fonts directly from Google for now and add the following
parts in the <head>
of the public/index.html
file by adding the following lines:
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
First we should clean up the document body
's margin.
I don't know why but the default user agent stylesheet has a margin: 8px;
setting.
Probably a leftover from earlier times which wasn't removed for not breaking existing websites or so.
Let's clean this up by adding a global style with styled-components
to our BlankPage
component.
// src/components/page-layout/BlankPage.tsx
import React, { FC, ReactNode, useEffect } from 'react';
import { useConfig } from '@packages/core/config';
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
body {
margin: 0;
}
`;
export type BlankPageProps = {
title: string;
children?: ReactNode;
};
export const BlankPage: FC<BlankPageProps> = (props) => {
const { companyName } = useConfig();
const titleParts: string[] = [];
if (props.title) {
titleParts.push(props.title);
}
if (companyName) {
titleParts.push(companyName);
}
useEffect(() => {
if (document) {
document.title = titleParts.join(' :: ');
}
});
return (
<>
<GlobalStyle />
{props.children}
</>
);
};
To be able to access the theme we should create one and provide it like so:
// src/components/theme/mui.ts
import { createTheme } from '@mui/material';
export const theme = createTheme();
This is the place where we can modify the styles for our MUI components.
Let's export it like below.
// src/components/theme/index.ts
export * from './mui';
Finally, we should to provide the theme in the ServiceProvider
and TestServiceProvider
like so
// src/ServiceProvider.tsx and src/TestServiceProvider.tsx
// add the following import statements:
import { ThemeProvider as MuiThemeProvider } from '@mui/material';
import { ThemeProvider as ScThemeProvider } from 'styled-components';
import { theme } from '@components/theme';
// wrap the other providers with the theme service providers
return (
<MuiThemeProvider theme={theme}>
<ScThemeProvider theme={theme}>
{/* other service providers */}
</ScThemeProvider>
</MuiThemeProvider>
);
We should provide two different link components. Both should play well with the props of @mui/material
's link component.
One link component should have the functionality of react-router-dom
's Link
to route to another url.
With the other link component it should be possible to only execute stuff by the onClick
property without routing.
We should create these components in the @packages/core
folder, to make it available for any other package or component
we have to create in the future.
// src/packages/core/routing/Link.tsx
import React, { FC, ReactNode } from 'react';
import { Link as MuiLink, LinkProps as MuiLinkProps } from '@mui/material';
import { Link as ReactRouterDomLink } from 'react-router-dom';
export type RoutingLinkProps = MuiLinkProps & {
to: string;
children?: ReactNode;
};
export const RoutingLink: FC<RoutingLinkProps> = (props) => {
return <MuiLink {...props} component={ReactRouterDomLink} />;
};
export type FunctionalLinkProps = MuiLinkProps & {
onClick: () => void;
};
export const FunctionalLink: FC<FunctionalLinkProps> = (props) => (
<MuiLink
{...props}
href="#"
onClick={(event) => {
event.preventDefault();
if (props.onClick) {
props.onClick();
}
}}
/>
);
As always, we leak our public available components of the package with an index.ts
file:
// src/packages/core/routing/index.ts
export * from './Link';
Now that we have MUI installed, we can use its components and provide a better nav bar with no effort.
So let's change our NavBarPage.tsx
, that it looks like so:
// src/components/page-layout/NavBarPage.tsx
import React, { FC, useState } from 'react';
import { BlankPage, BlankPageProps } from './BlankPage';
import { anonymousAuthUser, useCurrentUser, useCurrentUserRepository } from '@packages/core/auth';
import { useNavigate } from 'react-router-dom';
import { Button, Toolbar, Typography, Container, Menu, MenuItem } from '@mui/material';
import { useConfig } from '@packages/core/config';
import { Home } from '@mui/icons-material';
import { FunctionalLink, RoutingLink } from '@packages/core/routing';
const LoggedInUserMenu: FC = () => {
const navigate = useNavigate();
const currentUserRepo = useCurrentUserRepository();
const currentUser = useCurrentUser();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
if (currentUser.type !== 'authenticated') {
return null;
}
function logoutUser() {
currentUserRepo.setCurrentUser(anonymousAuthUser);
navigate('/');
}
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
setAnchorEl(event.currentTarget);
}
function closeMenu() {
setAnchorEl(null);
}
const isMenuOpen = !!anchorEl;
return (
<>
<Button
id="basic-button"
aria-controls={isMenuOpen ? 'basic-menu' : undefined}
aria-haspopup="true"
aria-expanded={isMenuOpen ? 'true' : undefined}
onClick={handleClick}>
{currentUser.data.username}
</Button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={isMenuOpen}
onClose={closeMenu}
MenuListProps={{ 'aria-labelledby': 'basic-button' }}>
<MenuItem
onClick={() => {
navigate('/user-management/my-settings');
closeMenu();
}}>
My settings
</MenuItem>
<MenuItem
onClick={() => {
logoutUser();
closeMenu();
}}>
Logout
</MenuItem>
</Menu>
</>
);
};
const Nav: FC = () => {
const { companyName } = useConfig();
const navigate = useNavigate();
const currentUserRepo = useCurrentUserRepository();
const currentUser = useCurrentUser();
const isLoggedIn = currentUser.type === 'authenticated';
function loginUser() {
currentUserRepo.setCurrentUser({
type: 'authenticated',
apiKey: 'foo',
data: {
id: 'foo',
username: 'Linus',
},
});
}
return (
<Toolbar sx={{ borderBottom: 1, borderColor: 'divider', marginBottom: '15px' }}>
<RoutingLink to="/">
<Home />
</RoutingLink>
<Typography component="h2" variant="h5" color="inherit" align="center" noWrap sx={{ flex: 1 }}>
{companyName}
</Typography>
{!isLoggedIn && (
<>
<FunctionalLink onClick={loginUser} noWrap variant="button" href="/" sx={{ p: 1, flexShrink: 0 }}>
Login
</FunctionalLink>{' '}
<Button variant="outlined" size="small" onClick={() => navigate('/auth/register')}>
Sign up
</Button>
</>
)}
{isLoggedIn && <LoggedInUserMenu />}
</Toolbar>
);
};
export type NavBarPageProps = BlankPageProps;
export const NavBarPage: FC<NavBarPageProps> = (props) => {
return (
<BlankPage title={props.title}>
<Nav />
<Container>{props.children}</Container>
</BlankPage>
);
};
Well done! I think it's time to have a look at translating things in the next chapter.