Micro Frontend with React

Micro Frontend with React

In this article, you will learn how to build micro-frontend apps using React. It is quite an uncommon article on my blog since I’m usually writing about Java, Spring Boot, or Kubernetes. However, sometimes you may want to build a nice-looking frontend for your backend written e.g. in Spring Boot. In this article, you will find a receipt for that. Our app will do some basic CRUD operations and communicate with the Spring Boot backend over the REST API. I’ll focus on simplifying your experience with React to show you which libraries to choose and how to use them. Let’s begin.

If you are also interested in Spring Boot and microservices you can my article about best practices for building microservices using the Spring Boot framework.

Source Code

If you would like to try this exercise yourself, you may always take a look at my source code. In order to do that, you need to clone my GitHub repository. This time we have two apps since there are backend and frontend. If you would like to run the Spring Boot directly from the code also clone the following repository. After that, just follow my instructions.

Prerequisites

Before we begin, we need to install some tools. Of course, you need npm to build and run our React app. I used a version 8.19.2 of npm. In order to run the Spring Boot backend app locally, you should have Docker or Maven with JDK.

Assuming you have Maven and JDK and you want to run it directly from the code just execute the following command:

$ mvn spring-boot:run

With Docker just run the app using the latest image from my registry:

$ docker run -d --name sample-spring-boot -p 8080:8080 \
  piomin/sample-spring-kotlin-microservice:latest

After running the Spring Boot app you can display the list of available REST endpoints by opening the Swagger UI page http://localhost:8080/swagger-ui.html.

Micro Frontend with React – Architecture

Here’s our architecture. First, we are going to run the Spring Boot app and expose it on the local port 8080. Then we will run the React app that listens on a port 3000 and communicates with the backend over REST API.

micro-frontend-react-arch

We will use the following React libraries:

  • MUI (Material UI for React) – React UI components, which implement Google’s Material Design 
  • React Redux – an implementation of Redux JS for React to centralize the state of apps using the store component 
  • Redux Saga – an intuitive Redux side effect manager that allows us to dispatch an action asynchronously and connect to the Redux store
  • Axios – the promise-based HTTP client for the browser and node.js
  • React Router – declarative, client-side routing for React

Later, I will show you how those libraries will help you to organize your project. For now, let’s just take a look at the structure of our source code. There are three components: Home displays the list of all persons, AddPerson allows adding of a new person, and GetPerson displays the details of a selected person.

micro-frontend-react-structure

Let’s take a look at our package.json file.

{
  "name": "react",
  "version": "1.0.0",
  "description": "React Micro Frontend",
  "keywords": [
    "react",
    "starter"
  ],
  "main": "src/index.js",
  "dependencies": {
    "@emotion/react": "11.10.4",
    "@emotion/styled": "11.10.4",
    "@mui/material": "5.10.8",
    "@mui/x-data-grid": "latest",
    "axios": "1.1.2",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-redux": "8.0.2",
    "react-router-dom": "6.4.2",
    "react-scripts": "5.0.1",
    "redux": "4.2.0",
    "redux-saga": "1.2.1"
  },
  "devDependencies": {
    "@babel/runtime": "7.13.8",
    "typescript": "4.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

We can create React apps using two different approaches. The first of them is based on functional components, while the second is based on class components. I won’t compare them, since these are React basics and you can read more about them in tutorials. I’ll choose the first approach based on functions.

Communicate with the Backend over REST API

Let’s start unusual – with the REST client implementation. We use the Axios library for communication over HTTP and redux-saga for watching and propagating events (actions). For each type of action, there are two functions. The “watch” function waits on dispatched action. Then it just calls another function for performing HTTP calls. All the “watch” functions are our sagas (in fact they implement the popular SAGA pattern), so we need to export them outside the module. The Axios client is pretty intuitive. We can call for example GET endpoint without any parameters or POST JSON payload. Here’s the implementation available in the sagas/index.js file.

import { call, put, takeEvery, all } from "redux-saga/effects";
import axios from "axios";
import { 
  ADD_PERSON, 
  ADD_PERSON_FAILURE, 
  ADD_PERSON_SUCCESS, 
  GET_ALL_PERSONS, 
  GET_ALL_PERSONS_FAILURE, 
  GET_ALL_PERSONS_SUCCESS, 
  GET_PERSON_BY_ID, 
  GET_PERSON_BY_ID_FAILURE, 
  GET_PERSON_BY_ID_SUCCESS } from "../actions/types";

const apiUrl = "http://localhost:8080/persons";

function* getPersonById(action) {
  try {
    const person = yield call(axios, apiUrl + "/" + action.payload.id);
    yield put({ type: GET_PERSON_BY_ID_SUCCESS, payload: person });
  } catch (e) {
    yield put({ type: GET_PERSON_BY_ID_FAILURE, message: e.message });
  }
}

function* getAllPersons(action) {
  try {
    const persons = yield call(axios, apiUrl);
    yield put({ type: GET_ALL_PERSONS_SUCCESS, payload: persons });
  } catch (e) {
    yield put({ type: GET_ALL_PERSONS_FAILURE, message: e.message });
  }
}

function* addPerson(action) {
  try {
    const person = yield call(axios, {
      method: "POST",
      url: apiUrl,
      data: action.payload
    });
    yield put({ type: ADD_PERSON_SUCCESS, payload: person });
  } catch (e) {
    yield put({ type: ADD_PERSON_FAILURE, message: e.message });
  }
}

function* watchGetPerson() {
  yield takeEvery(GET_PERSON_BY_ID, getPersonById);
}

function* watchGetAllPersons() {
  yield takeEvery(GET_ALL_PERSONS, getAllPersons);
}

function* watchAddPerson() {
  yield takeEvery(ADD_PERSON, addPerson);
}

export default function* rootSaga() {
  yield all([watchGetPerson(), watchGetAllPersons(), watchAddPerson()]);
}

Redux Saga works asynchronously. It listens for the action and propagates a new event after receiving a response from the backend. There are three actions handled by the component visible above: GET /persons, GET /persons/{id}, and POST /persons. Depending on the result they emit *_SUCCESS or *_FAILURE events. Here’s a dictionary in the file actions/types.js with all the events handled/emitted by our app:

export const GET_ALL_PERSONS = "GET_ALL_PERSONS";
export const GET_ALL_PERSONS_SUCCESS = "GET_ALL_PERSONS_SUCCESS";
export const GET_ALL_PERSONS_FAILURE = "GET_ALL_PERSONS_FAILURE";

export const GET_PERSON_BY_ID = "GET_PERSON_BY_ID";
export const GET_PERSON_BY_ID_SUCCESS = "GET_PERSON_BY_ID_SUCCESS";
export const GET_PERSON_BY_ID_FAILURE = "GET_PERSON_BY_ID_FAILURE";

export const ADD_PERSON = "ADD_PERSON";
export const ADD_PERSON_SUCCESS = "ADD_PERSON_SUCCESS";
export const ADD_PERSON_FAILURE = "ADD_PERSON_FAILURE";

Also, let’s take a look a the action/index.js file. It contains three functions for dispatching actions. Those functions are then used by the React components. Each action has a type field and payload. The payload may e.g. contain a body that is sent as a JSON to the backend (1).

import { 
    ADD_PERSON, 
    GET_PERSON_BY_ID, 
    GET_ALL_PERSONS } from "./types";

export function getPersonById(payload) {
  return { type: GET_PERSON_BY_ID, payload };
}

export function getAllPersons(payload) {
  return { type: GET_ALL_PERSONS, payload };
}

export function addPerson(payload) { // (1)
  return { type: ADD_PERSON, payload };
}

Configure React Redux and Redux Saga

To make everything work properly we need to prepare some configurations. In the previous step, we have already created an implementation of sagas responsible for handling asynchronous actions dispatched by the React components. Now, we need to configure the Redux Saga library to handle those actions properly. In the same step, we also create a Redux store to handle the current global state of the React app. The configuration is available in the store/index.js file.

import { createStore, applyMiddleware, compose } from "redux";
import createSagaMiddleware from "redux-saga";
import rootReducer from "../reducers/index";
import rootSaga from "../sagas/index";

const sagaMiddleware = createSagaMiddleware(); // (1)
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; // (2)

const store = createStore(
  rootReducer,
  composeEnhancers(applyMiddleware(sagaMiddleware))
); // (3)

sagaMiddleware.run(rootSaga); // (4)

export default store;

The only way to change the global state of the app is to take action. In order to handle the actions, let’s create a component called sagaMiddleware (1). Then we need to register sagas (4) and connect the store to the redux-saga middleware (3). We will also enable Redux Dev Tools for the Saga middleware (2). It would be helpful during development. The store requires reducers. That’s a very important part of the Redux concept. In redux nomenclature “reducer” is a function that takes a current state value and an action object that described “what happened”. As a result, it returns a new state value.

Here’s our reducer implementation provided in the reducers/index.js file:

import { 
  ADD_PERSON,
  ADD_PERSON_SUCCESS, 
  GET_ALL_PERSONS_SUCCESS, 
  GET_PERSON_BY_ID_SUCCESS } from "../actions/types";

const initialState = {
  persons: [],
  person: {},
  newPersonId: null,
}; // (1)

function rootReducer(state = initialState, action) {
  switch(action.type) {
    case GET_ALL_PERSONS_SUCCESS: // (2)
      return {
        ...state,
        persons: action.payload.data
      };
    case ADD_PERSON:
      return {
        ...state,
        person: action.payload.data,
        newPersonId: null
      };
    case ADD_PERSON_SUCCESS: // (3)
      return {
        ...state,
        person: {
          name: "",
          gender: "",
          age: 0
        },
        newPersonId: action.payload.data.id
      };
    case GET_PERSON_BY_ID_SUCCESS: // (4)
      return {
        ...state,
        person: action.payload.data
      };
    default:
      return state;
  }
}

export default rootReducer;

Let’s analyze what happened here. We need to define the initial state of the store for our micro frontend React app (1). It contains the list of all persons retrieved from the backend (persons), the current displayed or newly added person (person) and the id of a new person (newPersonId). For the GET_ALL_PERSONS action it puts the elements received from the backend API to the persons array (2). For the ADD_PERSON result, it resets the state of the person object and set the id of the new person in the newPersonId field (3). Finally, we set the current person details in the person object for the GET_PERSON_BY_ID result (4).

Create React Components

We have already created all the components responsible for handling actions, the state store, and communicating with the backend. It’s time to create our first React component. We will start with the Home component responsible for getting and displaying a list of all persons. Here’s the full code of the component available in components/Home.js. Let’s analyze step-by-step what happened here. The order of further steps is logical.

import { connect } from "react-redux";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; // (9)

import { Button, Stack } from "@mui/material";
import { DataGrid } from '@mui/x-data-grid';

import { getAllPersons } from "../actions/index"; // (4)

// (7)
const columns = [
  { field: 'id', headerName: 'ID', width: 70 },
  { field: 'name', headerName: 'Name', width: 130, editable: true },
  { field: 'age', headerName: 'Age', type: 'number', width: 90, editable: true },
  { field: 'gender', headerName: 'Gender', width: 100 },
];

function Home({ getAllPersons, persons }) { // (5)

  let navigate = useNavigate(); // (10)

  // (8)
  useEffect(() => {
    getAllPersons()
  }, []);

  function handleClick() { // (11)
    navigate("/add");
  }

  function handleSelection(p, e) { // (13)
    navigate("/details/" + p.id);
  }

  return(
    <Stack spacing={2}>
      <Stack direction="row">
        <Button variant="outlined" onClick={handleClick}>Add person</Button>
      </Stack>
      <div style={{ height: 400, width: '100%' }}> // (6)
        <DataGrid
          rows={persons}
          columns={columns}
          pageSize={5}
          onRowDoubleClick={handleSelection} // (12)
        />
      </div>
    </Stack>
  );
}

function mapStateToProps(state) { // (2)
  return {
    persons: state.persons,
  };
}

function mapDispatchToProps(dispatch) { // (3)
  return {
    getAllPersons: () => dispatch(getAllPersons({})),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Home); // (1)

(1) – we need to connect our component to the Redux store. The react-redux connect method takes two input arguments mapStateToProps and mapDispatchToProps

(2) – the mapStateToProps is used for selecting the part of the data from the store that the connected component needs. It’s frequently referred to as just mapState for short. The Home component requires the persons array from the global state store

(3) – as the second argument passed into connectmapDispatchToProps is used for dispatching actions to the store – dispatch is a function of the Redux store. You can call store.dispatch to dispatch an action. This is the only way to trigger a state change. Since we just need to dispatch the GET_ALL_PERSONS action in the Home component we define a single action there

(4) – we need to import the action definition

(5) – the actions and state fields mapped by the connect method need to be declared as the component props

(6) – we use the Material DataGrid component to display the table with persons. It takes the persons prop as the input argument. We also need to define a list of table columns (7).

(7) – the definition of columns contained by the DataGrid component. It displays the id, name, age and gender fields of each person on the list.

(8) – with the React useEffect method we dispatch the GET_ALL_PERSONS action on load. In fact, we are just calling the getAllPersons() function defined within the actions, which creates and fires events asynchronously

(9) – from the Home component we can navigate to the other app pages represented by two other components AddPerson and GetPerson. In order to do that we first need to import the useNavigate method provided by React Router.

(10) – let’s call the useNavigate method declared in the previous step to get a handle to the navigate component

(11) – there is a Material Button on the page that redirects us the /add context handled by the AddPerson component

(12) – firstly let’s add the onRowDoubleClick listener to our DataGrid. It fires after you double-click on the selected row from the table

(13) – then we get the id field of the row and navigate to the /details/:id context.

Configure React App and Routing

That could be our first step. However, now we can analyze from the perspective of all previously created components or definitions as a final part of our configuration. We need to import the Redux store definition (1) and our React components (2). We also need to configure routing for our three components (3) using React Router library. Especially the last path is interesting. We use a dynamic parameter based on the person id field. Finally, let’s set the store and router providers (4).

import React from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import {
  createBrowserRouter,
  RouterProvider
} from "react-router-dom";
import store from "./store/index"; // (1)
import Home from "./components/Home"; // (2)
import AddPerson from "./components/AddPerson";
import GetPerson from "./components/GetPerson";

const root = document.getElementById("root");
const rootReact = createRoot(root);

const router = createBrowserRouter([
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/add",
    element: <AddPerson />,
  },
  {
    path: "/details/:id",
    element: <GetPerson />,
  },
]); // (3)

rootReact.render(
  <Provider store={store}>
    <RouterProvider router={router} />
  </Provider>
); // (4)

Let’s build the app by executing the following command:

$ npm install

Now, we can run our micro frontend React app with the following command:

$ npm start

Here’s our app home page:

micro-frontend-react-main-page

Add and Get Data in React Micro Frontend

There are two other components responsible for adding (AddPerson) and getting (GetPerson) data. Let’s start with the AddPerson component. The logic of that component is pretty similar to the previously described Home component. We need to import the addPerson method form actions (1). We also use person and newPersonId field from the state store (2). The ADD_PERSON action is dispatched on the “Save” button clicked (3). After adding a new person we are displaying a message with the id generated by the backend app (4).

import { connect } from "react-redux";
import { Form } from "react-router-dom";
import { TextField, Button, MenuItem, Alert, Grid } from "@mui/material"

import { addPerson } from "../actions/index"; // (1)

function AddPerson({ addPerson, person, newPersonId }) { // (2)

  function handleChangeName(e) {
    person.name = e.target.value;
  }

  function handleChangeAge(e) {
    person.age = e.target.value;
  }

  function handleChangeGender(e) {
    person.gender = e.target.value;
  }

  function handleClick(e) {
    addPerson(person); // (3)
  }

  return(
    <Form method="post">
      <Grid container spacing={2} direction="column">
        <Grid item xs={6}> // (4)
          {newPersonId != null ?
          <Alert variant="filled" severity="success">New person added: {newPersonId}</Alert> : ""
          }
        </Grid>
        <Grid item xs={3}>
          <TextField id="name" label="Name" variant="outlined" onChange={handleChangeName} value={person?.name} />
        </Grid>
        <Grid item xs={3}>
          <TextField id="gender" select label="Gender" onChange={handleChangeGender} value={person?.gender} >
            <MenuItem value={'MALE'}>Male</MenuItem>
            <MenuItem value={'FEMALE'}>Female</MenuItem>
          </TextField>
        </Grid>
        <Grid item xs={3}>
          <TextField id="age" label="Age" inputProps={{ inputMode: 'numeric' }} onChange={handleChangeAge} value={person?.age} />
        </Grid>
        <Grid item xs={3}>
          <Button variant="outlined" onClick={handleClick}>Save</Button>
        </Grid>
      </Grid>
    </Form>
  );
}

function mapStateToProps(state) {
    return {
      person: state.person,
      newPersonId: state.newPersonId,
    };
  }
  
function mapDispatchToProps(dispatch) {
  return {
    addPerson: (payload) => dispatch(addPerson(payload)),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(AddPerson);

Here’s our page for adding a new person:

Just click the “SAVE” button. After a successful operation you will see the following message on the same page:

We can back to the list. As you see our new person is there:

Now we double-click on the selected row. I would probably need to work on the look of that component 🙂 But it works fine – it displays the details of the person with the id equal to 4.

Let’s take a look at the code of the component responsible for displaying those details. We need to import the getPersonById method from actions (1). The component dispatches the GET_PERSON_BY_ID action on the page load (2). It takes the id parameter from the route context path /details/:id with the React Router useParams method (3). Then it just displays all the current person fields (4).

import { connect } from "react-redux";
import React, { useEffect } from "react";
import { useParams } from "react-router-dom";
import { Paper, Avatar, Grid } from "@mui/material"

import { getPersonById } from "../actions/index"; // (1)

function GetPerson({ getPersonById, person }) {

  let { id } = useParams(); // (3)

  // (2)
  useEffect(() => {
    getPersonById({id: id})
  }, []);

  // (4)
  return(
    <Grid container spacing={2} direction="column">
      <Grid item direction="row">
        <Grid item><Avatar>U</Avatar></Grid> 
        <Grid item>USER DETAILS</Grid>
      </Grid>
      <Grid item xs={3}>
        <Paper>Name: <b>{person?.name}</b></Paper>
      </Grid>
      <Grid item xs={3}>
        <Paper>Gender: <b>{person?.gender}</b></Paper>
      </Grid>
      <Grid item xs={3}>
        <Paper>Age: <b>{person?.age}</b></Paper>
      </Grid>
    </Grid>
    
  )
}

function mapStateToProps(state) {
    return {
      person: state.person,
    };
  }
  
function mapDispatchToProps(dispatch) {
  return {
    getPersonById: (payload) => dispatch(getPersonById(payload)),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(GetPerson);

Final Thoughts

I read some tutorials about React, but I didn’t find any that is providing detailed, step-by-step instructions on how to build a micro frontend that communicates with the backend over REST API. Some of them were too complicated, some were too basic or outdated. My point is to give you an up-to-date receipt on how to build a micro-frontend using the most interesting and useful libraries that help you organize your project well.

2 COMMENTS

comments user
John Fuhr

Great article! You worked from the backend (Axios and Redux) to the front. Too many times I’ve seen articles that TEND to start with React and vaguely work their way through to the data.

    comments user
    piotr.minkowski

    Thanks!

Leave a Reply