Structuring a React project functionally
In this series of articles, we’ve been discussing Functional Programming (FP), and how to apply it for several tasks. But, how do we apply its concepts to, say, a React-based web application? In FP, we may talk about avoiding side effects, for example, but websites are obviously going to interact with the user and back-end APIs. Applying TDD (Test Driven Development) we will also find that, unless we plan things well, testing will be difficult, nearly impossible. Easy testing is a hallmark of well-designed functions, so we need a good functional overall design.
I’m usually working on front- and back-end projects all the time, so let’s consider how I configured a React 18 project (yes, we’re working with the latest React!) so we could develop the website but allow for easy testing. (By the way, that style also made it easy to work with Storybook, a tool I highly recommend.) In this article, we will discuss the structure of our project and how we managed to curb side effects, all by applying FP techniques we’ve already seen in previous articles; let’s get into it!
Of side effects
We have often written about side effects. In a previous article, Injecting for Purity, we discussed what we meant by side effects:
What are side effects? One category is when the program interacts with outside entities: the user, a database, another computer, a remote service. The second category is when code changes the global state: modifying some global variable or mutating an object or array received as an argument, for example. Note that side effects aren’t some sort of “collateral damage”: with our definition, something as trivial as logging to the console is considered a side effect — even if you totally planned to do so!
As we mentioned then, a website devoid of any side effects would genuinely be of no interest at all! The site wouldn’t be able to get input for the user, wouldn’t do API calls, or even allow navigating from page to page, since all of these actions change the state of the site in one way or another! In the mentioned article, we described how we could inject functionality to solve or minimize some problems; let’s expand that here to fully consider:
- how to work with API calls
- how to handle global state
- how to do navigation
We will start with a basic React 18 project, consider some standard ways of organizing components, pages, and the like, and then discuss the functional changes that we’ll be using.
Structuring a basic React project
You can create React projects by hand, but the most uncomplicated way by far is using the create-react-app
tool. To get the basic structure, do:
npx create-react-app sample-project
This creates a simple directory, and all your code should go in the /src
subdirectory. There we have:
index.js
andindex.css
, that set some things up and render theApp
componentApp.js
andApp.css
, the base component for the website- a few other files that do not concern us here.
According to common architecture patterns, we should add several other directories to better structure our project. We’ll be working with Context
; some differences would be expected with state management tools such as Redux or MobX, for example.
└── /src
├── /assets
├── /components
├── /context
├── /hooks
├── /pages
├── /services
└── /utils
The described directories are:
assets
includes icons, images, fonts, etc.components
includes all components that are used in several placescontext
(which I prefer to namestore
, but let’s keep the suggested name) will have theContext
definitions we’ll be using. We’ll talk more about this below.hooks
is reserved for any hooks that I may define.pages
has the main structure for my site; these components are usually aligned with the routing structure for the app. Each page will have a subdirectory of its own, with possibly some components used exclusively by it.services
(or possiblyapi
) has code to interact with external APIsutils
(orfunctions
) has global utility functions, used in several places.
I usually add a few directories of my own:
constants
, for all constants (duh!) used in the projecti18n
, for internationalization (i18n) concerns. I usually work withreact-i18next
for this; read more here.layouts
, to define layouts for the application, sharing, for instance, common headers or sidebars.routing
, to define all routes and navigation; we’ll see more about this below.storybook
, created by Storybook when you set it up for React, as described here. Note, however, that we won’t include stories here; they will reside next to the components they apply to.styles
, for color definitions, global styling, font sizes, and all such constants that apply to the app’s styles.
What we don’t have, is a test
directory; tests (usually written with Jest) go in the same directory as the component or function to which they apply. The complete structure would be as follows.
└── /src
├── /assets
├── /components
├── /constants
├── /context
├── /hooks
├── /i18n
├── /layouts
├── /pages
├── /routing
├── /services
├── /storybook
└── /utils
So far, everything seems quite normal — so what’s new? The answer lies in how we work with the store, APIs, and routing; let’s get into that.
Open Source Session Replay
OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.
Start enjoying your debugging experience - start using OpenReplay for free.
Addressing functional concerns
Three things complicate development and testing, because they go against the grain of what FP would require:
-
Working with the store, and directly modifying state, is a no-no. We don’t want components to directly access and modify the state of the app, for the very same reasons that using and modifying global values is frowned upon. Components will need access to certain parts of the state and may have to update it, but we want to do this in a controlled fashion.
-
A widespread pattern has components directly using APIs. (For instance, a dropdown to select a country could call a service to get the list of countries to show.) Testing components that do this kind of external access is complex, though Jest allows it through manual mocks.
-
The third problem for testing occurs if an action in some page causes navigation and uses routes; it can be solved using the testing library, but we’d rather go for something simpler, requiring less boilerplate.
All these behaviors are clear side effects, and as it happens, there’s a common solution for all of them: the use of injection. Let’s see how!
Working with state
How to work with global state by using Context
is the simplest solution. We’ll define the state using the useState(...)
hook, including both values and their corresponding update functions in the Context
. (See more about this hook here.) Any component that needs a value from the global state will be able to access it, and if it needs to update it, that will be possible through the update function. Let’s see a possible state, for an imaginary app that deals with singers, studios, and songs.
// store/index.jsx
import { createContext, useState } from "react";
const DataContext = createContext({});
const DataProvider = ({ children }) => {
const [globalSingers, setGlobalSingers] = useState([]);
const [globalSongs, setGlobalSongs] = useState([]);
const [globalStudios, setGlobalStudios] = useState([]);
const dataState = {
globalSingers,
globalSongs,
globalStudios,
setGlobalSingers,
setGlobalSongs,
setGlobalStudios,
};
return (
<DataContext.Provider value={dataState}>{children}</DataContext.Provider>
);
};
export { DataContext, DataProvider };
We export DataContext
(the context that will be imported by any component that requires something from the global state) and DataProvider
, which we’ll need in the main app. Oh, and if you wonder why everything’s called globalSomething
, it’s just something I like to do, to easily see that something is global, not local; you need not do this!
My index.js
would be as follows, though we’ll add more to it later. As we put our definitions in /store/index.jsx
, we may import from /store
; we’ll use this pattern for all our providers, as you’ll see.
// /index.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { DataProvider } from "./store";
const root = createRoot(document.getElementById("root"));
root.render(
<StrictMode>
<DataProvider>
...
</DataProvider>
</StrictMode>
);
If a component needs something from the global store, it will include code as follows.
import { useContext } from "react";
// ...
import { DataContext } from "../store";
// ...
export const someComponent = () => {
const { globalSingers, setGlobalSingers } = useContext(DataContext);
// ...
}
We have solved the first problem: components can get and set global values in a controlled way. How to test components that use context is a well-known pattern, so no problem there. We won’t get into details, but the important fact is that we’ve simplified testing and isolated side effects for a more functional style. Let’s move on to the second problem in our list, dealing with service calls.
Working with APIs
The second usual problem that we mentioned is that components sometimes need to call APIs, and that’s another side effect that we want to control. Fortunately, the same kind of solution that we discussed in the previous section still works. We will set up a global context that will include all functions that do API calls (maybe using fetch, Axios, SuperAgent, or the like; implementation details aren’t relevant) taken from our /services
directory. For instance, our imagined music app could require services to get, add, delete, or update an artist; get the songs that an artist sings; get the list of studios, etc.
// services/index.jsx
import { createContext } from "react";
import { getArtist } from ...
import { addArtist } from ...
import { deleteArtist } from ...
// ...
const ApiContext = createContext({});
const ApiProvider = () => {
const apiState = {
getArtist,
addArtist,
deleteArtist,
updateArtist,
getSongsOfArtist,
getAllStudios,
// ...
};
return <ApiContext.Provider value={apiState}>{children}</ApiContext.Provider>;
};
export { ApiContext, ApiProvider };
We’ll modify our initial index.js
file to refer to this new provider.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { DataProvider } from "./store";
import { ApiProvider } from "./services";
const root = createRoot(document.getElementById("root"));
root.render(
<StrictMode>
<ApiProvider>
<DataProvider>
...
</DataProvider>
</ApiProvider>
</StrictMode>
);
We won’t have to repeat details here. Again, as we defined the API context and provider in /services/index.jsx
, we import from /services
. An important detail: nobody should import anything else from that directory; you should go through the provider to use a service. As with global state, if a component needs to do an API call, it will just get the corresponding function from ApiContext
; tests will also be straightforward, providing mocks in a context. Again, we’ve provided a functional solution and managed to isolate the needed side effects. On to the last problem, working with navigation!
Working with Navigation
We’ve only got one problem left: dealing with navigation in the app… and it should be no surprise that, for a third time, we’ll use the solution of injecting functions through context! In our /routing
directory, we’d define all routes plus the corresponding navigation functions. For example, our imagined app could have a page for all singers (and one for a specific singer), another for songs, etc. Using react-router
and the useNavigate(...)
hook, code would look as follows.
// routing/index.jsx
import { createContext } from "react";
import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";
import {
AllSingers,
OneSinger,
Songs,
Page404
// ...
} from "../pages";
const ALL_SINGERS_ROUTE = "/allsingers";
const SINGER_ROUTE = "/onesinger/:id";
const SONGS_ROUTE = "/songs";
// ...
const RoutingContext = createContext({});
const RoutingProvider = ({ children }) => {
const navigate = useNavigate();
const routingState = {
navigateToAllSingers: () => navigate(ALL_SINGERS_ROUTE),
navigateToOneSinger: (id) => navigate(SINGER_ROUTE.replace(":id", id)),
navigateToSongs: () => navigate(SONGS_ROUTE),
// ...
};
return (
<RoutingContext.Provider value={routingState}>
{children}
</RoutingContext.Provider>
);
};
const RoutesProvider = () => {
return (
<BrowserRouter>
<RoutingProvider>
<Routes>
<Route path={ALL_SINGERS_ROUTE} element={<AllSingers />} />
<Route path={SINGER_ROUTE} element={<OneSinger />} />
<Route path={SONGS_ROUTE} element={<Songs />} />
...
<Route path="*" element={<Page404 />} />
</Routes>
</RoutingProvider>
</BrowserRouter>
);
};
export { RoutesProvider, RoutingContext };
There are several points of interest here:
- the
RoutesProvider
component defines and provides all routes for the application - For the third time, the usage of
/routing/index.jsx
allows us to directly import from/routing
- all pages are imported by this component, to be used in routing
- in a real app, we’d also have layout components, many more pages, protected routes, and obvious basic details like logging in and out - but all of this isn’t relevant for our discussion
- navigation is done through the
navigateToXXX(...)
functions only; these may include parameters: check outnavigateToOneSinger(...)
that creates a dynamic route based on a parameter. - a component that wants to navigate to a different page will get the corresponding function from the context and use it; the rest of the app won’t even “know” about routes, and all details about routes and navigation are encapsulated in our context and provider
The final version of our index.js
file would be as follows.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { DataProvider } from "./store";
import { ApiProvider } from "./services";
import { RoutesProvider } from "./routing";
const root = createRoot(document.getElementById("root"));
root.render(
<StrictMode>
<ApiProvider>
<DataProvider>
<RoutesProvider />
</DataProvider>
</ApiProvider>
</StrictMode>
);
Now our application is complete! Finally, testing will be along the same lines as in the two previous sections: just a matter of providing a mock function in a context and verifying that the component calls it correctly.
Conclusion
We’ve seen how to use the injection pattern to solve three problems in a functional way: accessing and modifying global state, calling external APIs, and doing navigation in the app. In all cases, solutions were based on using Context
. This allowed for easy testing, requiring only simple mocks. FP may be considered “more difficult”, but in this case I’d argue that the functional solution is quite general and simpler than all alternatives; a good win!