Services Layer approach in ReactJS

Resume

In this post I want to show you a technique I'm trying to implement in order to decouple the implementation of REST, GraphQL or whatever you use to communicate your frontend with a backend (backend, storage, local files, etc).

Motivation

Hi there. I'm a web developer. I've some experience building apps with old techniques with PHP, Java and .Net C#. So I've saw ugly stuff and other things that makes the programming experience a pain: hard to maintain, hard to debug, hard to scale, hard to test (maybe impossible).

I've been working with ReactJS since some years ago and I noticed something that caught my attention. Most developers are making the same mistakes we made in the past (me included of course).

I'm talking about spaghetti code, untestability, and implementation coupling.

So, well, I know there are some principles we can apply to make things easier (I'm talking of SOLID, DRY, KISS, etc.) and I want to make it better.

Services Layer approach

Ok, when we write a react component that will use some service connection we tend to do it in this way for example

import axios from "axios";
import {useState, useEffect} from "react";

export function OrdersList() {
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    axios.get("/orders")
      .then(({data}) => setOrders(data))
      .catch(e => console.error(e))
      .finally(() => setLoading(false));
  }, []);

  return (
    <ul>
      {orders.map(order => (
        <li key={order.id}>{order.id}</li>
      ))}
    </ul>
  );
}

It looks fine, didn't? But then if you have more components that implements the same endpoint? what if the endpoint changes? you will have to update it on each component. Also, when you need to add more treatments to the data like mapping or normalization, you will add more code. And finally, if you want to add an unit test you will probably use an axios mock strategy.

My proposal is to encapsulate the data fetching in a collection of functions (near to be repositories) that receive arguments if necessary and that returns the needed data.

async function getAll() {
  const result = await axios.get("/orders");
  return result.data || [];
}

export const ordersService = {
  getAll
};

Now we can use it in this way using dependency injection.

import {useState, useEffect} from "react";

// the ordersService is injected (dependencies injection)
export function OrdersList({ ordersService }) {
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    ordersService.getAll()
      .then(orders => setOrders(orders))
      .catch(e => console.error(e))
      .finally(() => setLoading(false));
  }, []);

  return (
    <ul>
      {orders.map(order => (
        <li key={order.id}>{order.id}</li>
      ))}
    </ul>
  );
}

and with that dependency injection we can easily write a mock no matter what kind of service we use (rest, graphql, etc) because only the "orders service" knows what is the magic behind

it ("Loads two orders") {
  const mockOrdersService = {
    getAll: async () => ([{ id: "mock-01" }, { id: "mock-02" }])
  }

  const { getByTestId } = render(<OrdersList ordersService={mockOrdersService} />);
  ...
}

Right now it seems very simple, and I'm happy for that. But I want you to see more advantages.

Please think you have to create a new order. You will use a post request, and the backend needs a specific payload.

{
  order: {
    notes: "Extra cheese",
    items: [{ sku: "hamburger-01" }]
  },
  customer: {
    customer_id: "01",
    registered_customer: true,
    not_registered_customer_name: null
  }
}

In this case we can add a new function at the service layer in this way:

async function sendOrder({
  notes,
  items,
  client_id,
  not_registered_customer_name = null
}) {
  const data = {
    order: {
      notes,
      items
    },
    customer: {
      customer_id,
      not_registered_customer_name,
      registered_customer: !!customer_id
    }
  };

  const result = await axios.post("/orders", data);
  return result.data || null;
}

export const ordersService = {
  getAll,
  sendOrder
}

Now if we need to create the order we just pass the needed arguments and the function will format the data

ordersService.sendOrder({
  client_id: "01",
  notes: "Extra cheese",
  items: [{ sku: "hamburger-01" }]
});

With this approach we are decoupling the implementation details, avoiding code repetition, allowing the testability. And just by separating the code concerns.

Then, I would like to talk about the separation of logic from the UI by using react Hooks and a hook I've designed to work like the graphql useQuery hook (I love that hook, but causes hard to maintain code)... but I think it's better to wait for your feedback in order to give a better proposal.

Also, you can give me some feedback about my writing on english. I will appreciate it a lot. Peace! ✌️

34