The Lego Approach to Frontend Development: Using Micro Frontends to Build Reusable UI Components

The Lego Approach to Frontend Development: Using Micro Frontends to Build Reusable UI Components

From monolithic to modular: How micro frontends can help you build a more flexible and maintainable frontend architecture.

The Lego approach to frontend development is based on the idea of building a frontend application out of modular, reusable UI components, much like building a structure out of Lego bricks. Each UI component is self-contained and can be combined with other components to form a complete application.

To implement this approach, we can use micro frontends, which are small, independent frontend applications that can be combined to form a larger application. Each micro frontend can be responsible for rendering a specific UI component, such as a navigation menu, a product list, or a shopping cart. By breaking down an application into smaller, independent micro frontends, developers can create a more modular and flexible architecture.

Benefits of the Lego Approach

There are several benefits to using the Lego approach to frontend development:

  • Reusability: UI components built as micro frontends can be reused across different applications, saving development time and effort.

  • Modularity: Each micro frontend is self-contained and can be developed and deployed independently, making it easier to maintain and update the application.

  • Scalability: The Lego approach can help to manage the complexity of large applications by breaking them down into smaller, more manageable parts.

  • Flexibility: Different teams can work on different micro frontends simultaneously, which can increase productivity and flexibility.

Drawbacks of the Lego Approach

Despite the benefits of the Lego approach, there are also some drawbacks to consider:

  • Complexity: Combining multiple micro frontends into a larger application can be complex and require careful coordination.

  • Communication: Micro frontends must communicate with each other to ensure a cohesive user experience, which can add complexity and overhead.

  • Performance: Combining multiple micro frontends can increase the number of requests and impact performance if not properly managed.

  • Testing: Testing micro frontends can be more complex than testing a monolithic application, and require additional tools and frameworks to manage.

Let's go through this with an example. Imagine you are building a website for an online fashion retailer. This application can be divided into different micro frontends for the homepage, product listing, product details, and checkout. Instead of building a monolithic application where all the code for the entire application is contained in one giant codebase, you can use micro frontends to build separate UI components for each business feature and combine them to form the final website.

Let us build this using NX. NX is a set of tools and libraries that helps developers build scalable and maintainable applications using the monorepo architecture. It provides features such as code generation, testing, and dependency management to streamline development workflows.

  1. Create a new NX workspace with the following command

     npx create-nx-workspace@latest ModularMart --preset=ts
    
  2. Create a micro-frontend application for the products list and checkout. Each micro frontend can be developed and deployed independently, allowing for faster development and easier maintenance.

     npm install -D @nrwl/react
     npx nx g @nrwl/react:app product-list
     npx nx g @nrwl/react:app checkout
    
  3. Create a common-components library, for the scope of this example we will just create a navigation bar as a common component which will be used in both the micro-frontends. The UI components can be reused across multiple pages and even across different websites, providing a consistent and seamless user experience.

     npx nx generate @nrwl/js:library common-components
    
  4. Create the navbar component in the library. This navbar component will be used in both the micro front ends i.e Product List and checkout.

     import React from 'react';
    
     type NavProps = {
       productListLink: string;
       checkoutLink: string;
     };
    
     export const Navbar: React.FC<NavProps> = ({ productListLink, checkoutLink }) => {
       return (
         <nav>
           <ul>
             <li>
               <a href={productListLink}>Product List</a>
             </li>
             <li>
               <a href={checkoutLink}>Checkout</a>
             </li>
           </ul>
         </nav>
       );
     };
    
  5. The product listing micro-frontend displays a list of products to users. We will create two components here, one is ProductCard which displays a single product in a card-like format. Includes the product name, image, price, and a button to add the product to the cart. Second is the ProductList component which displays a list of products using the ProductCard component. Receives an array of products as props, along with a callback function to handle adding products to the cart.

     import React from 'react';
     import styled from 'styled-components';
    
     interface ProductCardProps {
       product: {
         id: number;
         name: string;
         image: string;
         description: string;
         price: number;
       };
       onAddToCart: (id: number) => void;
     }
    
     const Container = styled.div`
       display: flex;
       flex-direction: column;
       align-items: center;
       justify-content: space-between;
       background-color: #fff;
       padding: 20px;
       border-radius: 5px;
       box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
     `;
    
     const Image = styled.img`
       max-width: 100%;
       height: auto;
       margin-bottom: 10px;
     `;
    
     const Name = styled.h2`
       font-size: 18px;
       font-weight: bold;
       margin: 0;
     `;
    
     const Price = styled.p`
       font-size: 16px;
       margin: 10px 0;
     `;
    
     const Button = styled.button`
       background-color: #333;
       color: #fff;
       padding: 10px 20px;
       border-radius: 5px;
       border: none;
       font-size: 18px;
       cursor: pointer;
       &:hover {
         background-color: #666;
       }
     `;
    
     const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
       const handleAddToCart = () => {
         onAddToCart(product.id);
       };
    
       return (
         <Container>
           <Image src={product.image} alt={product.name} />
           <Name>{product.name}</Name>
           <Price>${product.price.toFixed(2)}</Price>
           <Button onClick={handleAddToCart}>Add to Cart</Button>
         </Container>
       );
     };
    
     export default ProductCard;
    
     import React from 'react';
     import styled from 'styled-components';
     import ProductCard from './ProductCard';
    
     interface ProductListProps {
       products: {
         id: number;
         name: string;
         image: string;
         description: string;
         price: number;
       }[];
       onAddToCart: (id: number) => void;
     }
    
     const Container = styled.div`
       display: flex;
       flex-wrap: wrap;
       justify-content: space-between;
     `;
    
     const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
       return (
         <Container>
           {products.map((product) => (
             <ProductCard
               key={product.id}
               product={product}
               onAddToCart={onAddToCart}
             />
           ))}
         </Container>
       );
     };
    
     export default ProductList;
    
  6. Create the checkout component in the checkout micro frontend. The checkout micro-frontend can have several components such as a shipping form, payment form and order summary. For the scope of this example let's just create a simple checkout component which lists the selected product's name along with its price and calculates the total price of all the products selected.

     import React, { useState } from 'react';
    
     interface Product {
       id: number;
       name: string;
       price: number;
     }
    
     interface CheckoutProps {
       products: Product[];
     }
    
     const Checkout: React.FC<CheckoutProps> = ({ products }) => {
       const [total, setTotal] = useState(0);
    
       const calculateTotal = (): void => {
         const newTotal = products.reduce((acc, product) => acc + product.price, 0);
         setTotal(newTotal);
       };
    
       return (
         <div>
           <h2>Checkout</h2>
           <ul>
             {products.map(product => (
               <li key={product.id}>
                 {product.name} - ${product.price}
               </li>
             ))}
           </ul>
           <button onClick={calculateTotal}>Calculate Total</button>
           <p>Total: ${total}</p>
         </div>
       );
     };
    
     export default Checkout;
    
  7. Next, we are going to create an express application for the communication of data between the micro front ends. We are going to create 3 endpoints: one to get all the products which will be displayed on the ProductList component, the second is to update the products which are added to the card and the third is to get the list of products which are added to the cart. This list will be used on the CheckoutComponent.

     npx nx generate @nrwl/node:add products-api
    
     import express from 'express';
     import cors from 'cors';
    
     const host = process.env.HOST ?? 'localhost';
     const port = process.env.PORT ? Number(process.env.PORT) : 4000;
    
     interface Product {
       id: number;
       name: string;
       price: number;
       description: string;
       image: string;
     }
    
     const products: Product[] = [
       {
         id: 1,
         name: 'Product 1',
         price: 9.99,
         description: 'This is product 1.',
         image: 'https://picsum.photos/200/300.jpg',
       },
       {
         id: 2,
         name: 'Product 2',
         price: 19.99,
         description: 'This is product 2.',
         image: 'https://picsum.photos/id/237/200/300',
       },
       {
         id: 3,
         name: 'Product 3',
         price: 29.99,
         description: 'This is product 3.',
         image: 'https://picsum.photos/id/236/200/300',
       },
       {
         id: 4,
         name: 'Product 4',
         price: 59.99,
         description: 'This is product 3.',
         image: 'https://picsum.photos/id/238/200/300',
       },
     ];
    
     const app = express();
     let productsAdded = [];
     app.use(cors());
     app.use(express.json());
    
     app.use(express.urlencoded({ extended: true }))
    
     app.get('/products', (req, res) => {
       res.json(products);
     });
    
     app.get('/checkout/products', (req, res) => {
       res.json(productsAdded);
     });
    
     app.post('/products', (req, res) => {
       console.log(req.body)
       productsAdded=[...productsAdded, ...req.body.products]  
       res.send({ message: 'products updated', productsAdded });
     });
    
     app.listen(port, host, () => {
       console.log(`[ ready ] http://${host}:${port}`);
     });
    
  8. Call the get and update product endpoint in the product list micro front end and call the get checkout products endpoint in the checkout micro front end.

     //Checkout App.tsx
     import {Navbar} from '@modular-mart/common-components';
     import Checkout from './CheckoutComponent';
     import { useEffect, useState } from 'react';
    
     interface Product {
       id: number;
       name: string;
       price: number;
       description: string;
       image: string;
     }
    
     export function App() {
       const [products, setProducts] = useState<Product[]>([])
       useEffect(()=>{
         fetch('http://localhost:4000/checkout/products')
         .then(res => {
          return res.json()
         })
         .then(items=>{setProducts(items)})
       },[])
       const productListLink = 'http://localhost:3001';
       const checkoutLink = 'http://localhost:3000';
       return (
      <><Navbar productListLink={productListLink} checkoutLink={checkoutLink}></Navbar>
      <Checkout products={products}/></>
       );
     }
    
     export default App;
    
import React from 'react';
import styled from 'styled-components';
import ProductCard from './ProductCard';

interface ProductListProps {
  products: {
    id: number;
    name: string;
    image: string;
    description: string;
    price: number;
  }[];
  onAddToCart: (id: number) => void;
}

const Container = styled.div`
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
`;

const ProductList: React.FC<ProductListProps> = ({ products, onAddToCart }) => {
  return (
    <Container>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={onAddToCart}
        />
      ))}
    </Container>
  );
};

export default ProductList;

That's it! You now have two micro frontends for the product list and checkout features, with a shared library for the common UI components. You can continue to add more micro frontends for other features and reuse the shared components across all of them. You can access the full code for the example here.

Overall, using micro frontends to build reusable UI components is a powerful approach to frontend development that can help teams build complex applications faster, with better code organization and maintainability.