DEV Community

juanIgnacioEchaide
juanIgnacioEchaide

Posted on

Migrating UI library shouldn't be a pain in the stack (2)

State rules

We want to obtain a segregable or splittable UI decoupling the business logic as much as possible from UI components.

We said that all the api calls will result a reaction to state changes. That’s the reason why we need to be able to access this state from anywhere.

Context API is a great tool for that. You could choose a third party library for state-management such as Redux, ReactQuery, Recoil, and others.

Even when those are great tools with different pros and cons in each case I decided to use Context API because it comes with the React megalibrary. We won’t nothing else than ReactJS to manage our state.

This way we are going to have a QueryProvider. This high order component will wrap up the entire App and allow us to access the state to read and/or modify it from every component avoiding prop-drilling and code repetition.

This provider will have a reactive logic itself listening to the VIEW we are navigating and the parameters user selects.

The main concern is strictly to provide interaction with the state. But it must update itself to release UI components from the responsibility of triggering API calls.

It will handle the UseQueryByView custom hook we analysed before and a reducer that receive actions and manages the updates of state.

We create a Context as usual and declare the provider with its value (the state indeed and the dispatch function from reducer). Nothing different from a regular context (src\context\query\QueryProvider.tsx):

const QueryContext =  createContext<ContextValue<BaseState>>(defaultContextValue);
Enter fullscreen mode Exit fullscreen mode
const QueryProvider = ({ children }: any) => {
  const [state, dispatch] = useReducer(BaseReducer, defaultState);
  const viewPath = getLocationPath();
  const value = { state, dispatch };
Enter fullscreen mode Exit fullscreen mode

We can destructure the dynamic queries from our hook this way:

  const {
    allQuery,
    byPageQuery,
    byIdQuery,
    searchQuery,
    updateDispatch,
    displayGenericError,
    setView,
  } = UseQueryByView();
Enter fullscreen mode Exit fullscreen mode

And there is only one thing left to make it work, the logic that reacts to state changes triggering calls.

This is the concern of the fetchDataByPage function, which I divided into three little conditioned blocks for the three scenarios I wanted to handle.

With no parameters at all the “allQuery” of the corresponding view should be triggered.

If the id parameter with a value different from default 0 the byIdQuery should be triggered.

If the page parameter changes it does the proper thing with byPageQuery.

It's very important to be strict with the updates. The comments on useEffect’s dependency arrays, memorization and useCallback usage are essential to obtain the wanted result.

But also, if you play changing them you will found out that infinite loops and preventive crashes come around.

Having ownership means to have full responsibility and not just simply rely on the library and usual boilerplate.

Let’s see how it is implemented the allQuery block (src\context\query\QueryProvider.tsx):

  const fetchDataByPage = useCallback(() => {
    if (state?.view && state?.view !== VIEW.HOME) {
      if (state?.pageParam === 0) {
        allQuery(state?.view)
          .then((data: SwapiResponse<People | Planet | Starship>) => {
            const payload = setUpdatePayload(data);
            return dispatch(updateDispatch(state?.view, payload));
          })
          .catch((err) => dispatch(displayGenericError()));
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state?.view, state?.pageParam]);
Enter fullscreen mode Exit fullscreen mode

A little thing to mention is the setUdatePayload function that models our payload to correctly store it in our state.

Strictly speaking it is like a little mapper than transform the structure retrieved by services into a data model our business logic needs.

The returned type is precisely de UpdatePayload. Please bear in mind these details because they are key to simplify the handling data.

/* abstraction to partially update state for pagination */
const setUpdatePayload = (data: SwapiResponse<any>): UpdatePayload<any> => {
    return {
        currentPage: getPageFromUri(data?.next) - 1,
        nextPage: getPageFromUri(data?.next),
        prevPage: getPageFromUri(data?.previous),
        results: data?.results,
        nextUri: data?.next,
        prevUri: data?.previous,
    } as unknown as UpdatePayload<any>
}
Enter fullscreen mode Exit fullscreen mode

Finally, let’s see the most important of the state management in the way we implemented our context, the reducer (src\context\query\BaseReducer.ts).

This reducer works with a switch structure but you could implement it as object literals as we did for our queryByView.

The switched value is a constant stored in an enum call ActionType (src\common\constants\context.ts):

enum ActionType {
    SetLoading = "setLoading",
    SetError = "SetError",
    SetView = "SetView",
    SetPrevUri = "SetPrevUri",
    SetNextUri = "SetNextUri",
    ClearError = "ClearError",
   {}
}
Enter fullscreen mode Exit fullscreen mode

First part of the BaseReducer handles the UI aspects and navigation:

const BaseReducer = (state: BaseState, action: Action): BaseState => {

    switch (action.type) {
        //// UI FLAGS & NAVIGATION 
        case ActionType.SetLoading: return {
            ...state,
            loading: action.payload
        }
        case ActionType.SetError: return {
            ...state,
            error: true,
            errorMessage: action.payload
        }
        case ActionType.ClearError: return {
            ...state,
            error: false,
            errorMessage: ''
        }
        case ActionType.SetView: return {
            ...state,
            view: action.payload
        }
{}
}

Enter fullscreen mode Exit fullscreen mode

The most important of those actions is to set the view, our key to guide all our state changes.

The main target is to have available the flags of loading, error, etc., on state and then render whatever you want to communicate that state to users thru UI components.

The second part of this reducer handles the update of the items data retrieved from the Star Wars API:

        ///// POPULATES PERSISTENT GLOBAL STATE 
        case ActionType.UpdatePeople: return {
            ...updateAfterLoad(StateEntity.People, action.payload, state)
        }
        case ActionType.UpdateSpecies: return {
            ...updateAfterLoad(StateEntity.Species, action.payload, state)
        }
        case ActionType.UpdatePlanets: return {
            ...updateAfterLoad(StateEntity.Planets, action.payload, state)
        }

        {. . .}

Enter fullscreen mode Exit fullscreen mode

The updateAfterLoad function that we return with spread operator makes the dynamic update by the corresponding entity, populating the portion of the state with the proper payload, and of course we return an entire state.

Because our reducer has to be a pure function that keeps the previous state merged with the update made by the current action we need to pass as parameter the full state.

Let’s see the function (src\common\utils\helpers.ts):

/* abstraction to partially update state after response */
const updateAfterLoad = (
    entity: StateEntity, 
    payload: any, 
    state: BaseState, 
    currentPage?: number, 
    totalPages?:number
    ) => {
    return {
        ...state,
        [entity]: payload.results,
        loading: false,
        error: false,
        errorMessage: '',
        prevPage: payload.previous,
        nextPage: payload.nextPage,
        currentPage: payload.currentPage,
        nextUri: payload.nextUri,
        prevUri: payload.prevUri,
        displayed: {
            ...state.displayed,
            [entity]: payload.results
        },
    } as BaseState
};

Enter fullscreen mode Exit fullscreen mode

A constant of StateEntity is used as a computed property to match the entity state we want to overwrite, this way we can reuse this logic in our reducer for every case of items to be displayed.

Also because of the backend pagination of the API we are using we need to keep track of it in the state.

Like all the rest of this example we try to take the most advantage of abstraction, generics, and computed properties as possible to not repeat code.

Services layer

I personally like so much the layer stratification of the application from Clean Architecture.

The architecture I am proposing here follow this principle. Our UI layer is yet to be done but of course our target is to implement it segregated enough to not suffer future migrations or changes.

The data layer is represented for our QueryProvider partially.

A good practice would be to create another context that properly addresses the data modelling and related issues as a child of the QueryProvider, listening the state and transforming data into static content to be provided or served to the UI layer.

We are going to manage these issues on the PageTemplate component (our only page, strictly speaking).

Our services layer consists of an axios instance:

const apiClient = axios.create({
  baseURL: URI.BASE,
  responseType: "json",
  headers: {
    "Content-Type": "application/json"
  },
});

Enter fullscreen mode Exit fullscreen mode

An object named api contains keys for each screen, and values with the signatures of each API Call. You might splitted if you are working folder architecture which segregates modules or features isolated. I decided to declare one single object to keep the monolith structure. Like this (src\services\apiClient.ts)

const api = {
    people: {
        getAll: (): Promise<AxiosResponse<SwapiResponse<any>>> => {
            const data = apiClient.get(URI.PEOPLE);
            return data
        },
        getByPage: (page: number): Promise<AxiosResponse<SwapiResponse<People>>> => {
            const data = apiClient.get(`${URI.PEOPLE}/?page=${page}`);
            return data
        },
        getById: (id: string | number): Promise<AxiosResponse<SwapiResponse<People>>> => {
            const data = apiClient.get(`${URI.PEOPLE}/${id}`);
            return data
        },
        search: (params: string ): Promise<AxiosResponse<SwapiResponse<People>>> => {
            const data = apiClient.get(`${URI.PEOPLE}?search=${params}`);
            return data
        },
{}
    },
Enter fullscreen mode Exit fullscreen mode

This instance also admits interceptors that allows to manage centrally some response’s code.

This is useful if you are working end to end with API Rest.

If you are using GraphQL or other tools that do not follow API Rest codes you could design your own interceptors to differentiate each case in the 400 code that Graph use to retrieve when the query is bad structured, or parameters are missing.

The best practice is to have your own error dictionary, like the queryByView object and the viewStateLogic type we’ve seen before.

// centralized error handling thru interceptors
apiClient.interceptors.response.use(
  (response: AxiosResponse) => response,
  async (error: AxiosError) => {
    if (!error.response) {
      return Promise.reject(error);
    }
    switch (error.response.status) {
      case 401:
        throw Error("Unauthorized user");
      default:
        throw Error(error.message.toString());
    }
  }
);

export { apiClient };
Enter fullscreen mode Exit fullscreen mode

There is another layer of abtraction inside the services layer consisting of one file per endpoint and the implementation of the concrete call.

I consider this layer fundamental to be really open to changes in the service layer despite this concrete example does not necessary requires it. Let’s see the case of people screen:

import { api } from "./api"

const getPeopleById = async (id: string | number) => {
    const { data } = await api.people.getById(id)
    return data
}

const getAllPeople = async () => {
    const { data } = await api.people.getAll()
    return data
}

const getPeopleByPage = async (page: number) => {
    const { data } = await api.people.getByPage(page)
    return data
}

const searchPeople = async (params: string) => {
    const { data } = await api.people.search(params)
    return data
}

export { getPeopleById, getAllPeople, getPeopleByPage, searchPeople }

Enter fullscreen mode Exit fullscreen mode


typescript
This services layer could be implemented differently, of course. You might decide to go for a custom hook in the way of a useFetch to be reused in each call. It is not the core hypothesis we are trying to prove, and it does not conflict if is properly segregated.

Side by side with routing

This is our last step before dive into UI aspects and the reusability we are after to.

I decided to work the routing of our example with react-router-dom.

Nothing different from standard codebase except for two considerations:
a) our routes are also views stored in the state and,
b) we are going to render one single element for every routes. We are trying not only to have a segregable UI, also we need an optimized one.

We are going to work with one single template component cached in our browser exclusively updating the items we want to display at lists.

This decision will also make our job easier if we decide for any reason to migrate our UI third party libraries or even if a design change comes up avoiding the rework we might have if the logic we put in our monolith would be at our components.

The AppRouter component is the one concerned of routing the views and updating the render items (src\AppRouter.tsx):

import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import { ROUTES } from "./common/constants";
import { VIEW } from "./common/constants/uri";
import Home from "./pages/home/Home";

const RoutesStack = [
  { path: ROUTES.BASE, view: VIEW.DEFAULT, name: VIEW.DEFAULT },
  { path: ROUTES.HOME, view: VIEW.HOME, name: VIEW.HOME },
  { path: ROUTES.PEOPLE, view: VIEW.PEOPLE, name: VIEW.PEOPLE },
  { path: ROUTES.PLANETS, view: VIEW.PLANETS, name: VIEW.PLANETS },
  { path: ROUTES.STARSHIP, view: VIEW.STARSHIP, name: VIEW.STARSHIP },
  { path: ROUTES.FILMS, view: VIEW.FILMS, name: VIEW.FILMS },
  { path: ROUTES.VEHICLES, view: VIEW.VEHICLES, name: VIEW.VEHICLES },
  { path: ROUTES.SPECIES, view: VIEW.SPECIES, name: VIEW.SPECIES },
];

const AppRouter = () => {
  return (
    <Router>
      <Routes>
        {RoutesStack.map((route) => (
          <Route path={route.path} element={<Home/>} key={route.path} />
        ))}
      </Routes>
    </Router>
  );
};

export { AppRouter, RoutesStack };
Enter fullscreen mode Exit fullscreen mode

I declared an array with the RoutesStack not using the Routes structure react-router-dom provides to not repeat the element we are going to render.

This way our app always is at the same component not only at the same index.html page.

This container is good enough for a webapp where you only to display items. But you could enhance it in a more complex way with several templates.

he other consideration regarding routes and views has to be take into account when we redirect thru the click function of the respective route.

We have to trigger both functions: react-router-dom linkng and the dispatch to store of the corresponding view. If we go side-by-side with routing all our monolith will provide us with its benefits.

To be continued on Part III

Top comments (0)