DEV Community

juanIgnacioEchaide
juanIgnacioEchaide

Posted on

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

Keep your React app opened to style changes

Image description

The issue. The lead-lined lifejacket

Every time that a project starts we must make a decision of which UI library to use, or we can take the hardest road of making our own "lib".

I personally recommend the second choice because you will have full ownership of your components, without deprecation risk or unexpected incompatibilities with other third-party libraries.

However, we must consider the state of the project. This approach could not be the best for an MVP or a project with less than 2 years.

Changes and obstacles will come up and we might consider needing to migrate.

Then, we found ourselves between two awful possibilities:
a) injection of a lot of dependencies that will look good but with high performance and maintenance cost,
b) facing a gigantic refactor that will result in almost developing everything since the beginning again.

Our UI library was supposed to make us easy to float avoiding a lot of code, but it made us sink in re-work.

I will give you some guidelines through this article (divided in three parts) to enough split your code to make UI migration possible with reasonable effort.

Our core concept is represented in this chart:

Image description

The case study: little Star Wars app

I will take one of the most used challenges for recruiting which is the Star Wars API. It’s a simple example, just one of thousands of possibilities.

I encourage you to innovate and try to design a new and creative architecture that allows you to easily migrate UI libraries.

Even when the architecture of this example does not follow an orthodox model it does rely on SOLID principles, clean or onion architecture, flux architecture, and React’s good practices.

This architecture I’m proposing is nothing else but an extension of the criteria that the React mega library as well as third-party dependencies most used with it rely on.

The target of this article is to stimulate all those that find React a fascinating tool to dare to create, dare to innovate.

The monolith

We will rely on two JavaScript (and TypeScript) typical features that will allow us to work dynamically with the values we use computed properties and object literals.

Bear in mind that JS and TS strength is managing arrays and objects.

Our decoupled monolith will have another feature which it’s inspired in flux architecture.

We are going to trace every behaviour by its view. Our guide will be a simple string value contained in a simple Enum data structure TS provides us.

Even when some other features will be triggered by another state values (e.g.: API call parameters) the view is our most important value.

Listening the values on state behaviour will be triggered only when an update of these values take place. Same criterion for the update of UI components.

The target is to have absolute control of update, and make it work as a reaction of UI events such as navigation or parameter selection reducing unpredictability as much as possible.

Disclaimers

This intent might not be the best for your first React App.

We will design our codebase very differently from current standards and even when I am confident of the guidelines I am following is yet to be fully tested and discussed.

On the other hand, I must assume you already work with React and have some experience developing applications in general because of extension limits.

You will not see pretty UI designs here. It is not possible to achieve all goals in the case study example.

We will conform ourselves with behaviour and functioning dependency injection from third party UI libraries, and nothing else. I will leave to you the UI design to make it look cool.

The point I am wanting to prove is that decoupling itself, then all UX and UI aspects taking place on representational components must be left outside.

Finally, I will not explain every line of the codebase but the most representative of each block so you can continue to dive into it by yourself.

To offer a fully functional example I had to develop much more code than an article could fully explain.

Nevertheless, I tried to be enough consistent to make the project easy to understand with a few hints I will give here.

Yes, there is a repo

You will find at this link the repo with the codebase we will follow in the current article:

I wanted to provide a practical way to comprehend the point I am trying to make.

That’s the reason why you will find only the domain I have referred to before and nothing else regarding UI component but a simple screen writing the data on state.

I want you to see by yourself the monolith with our frontend business logic is autonomous enough to sustain itself.

The decoupling works by the time that our app works without crashing even when do not render any proper representational component to give a nice UX.

But to entirely prove the assertion behind this article you will find in the following branch, the first UI example using styled-components. (Remember to run an install every time you switch).

The second example with representational components using third-party libraries works with Material UI.

Actually, both libraries combine perfectly despite I give separated examples just to make clear the core concepts.

The theatre of operations

There is a sort of city hall of our architecture. And one of its corner stones is the following object literals which a partially paste below (src\hooks\query\QueryByView.ts):

    [VIEW.PEOPLE]: {
      allQuery: () => {
        return getAllPeople();
      },
      byPageQuery: (page: number) => {
        return getPeopleByPage(page);
      },
      byIdQuery: (id: number) => {
        return getPeopleById(id);
      },
      searchQuery: (stringParam: string) => {
        return searchPeople(stringParam);
      },
      updateFn: (data: UpdatePayload<People>): Action => {
        return QueryActions.UpdatePeople(data) as Action;
      },
    },
    //{. . .}
      },
    },
  };

Enter fullscreen mode Exit fullscreen mode

The object is much bigger but it is just a repetition of this section.

We have a computed property as key. This means that is value is computed dynamically, JavaScript compiler look for the key inside the object comparing with a value provided as parameter, and “stops” when it matches a key (view).

The values inside this object are objects themselves and its keys are the query types we will use in each API URL.

The Star Wars API results to be very comfortable to be worked this way: one URL per entity and every endpoint has its own “getAll”, “getById”, “getByPage”, etc.

The object value has a signature as keys, it actually works as a segregated interface opened to extension and closed to changes following SOLID.

Don’t be confused because of the object structure.

Every signature has an arrow function as value which triggers the proper API call declared at the service layer we will soon analyze.

We can keep consistency thru the ViewQueries type (src\common\models\entities.ts):

type ViewQueries = Record<VIEW | any, ViewStateLogic>
Enter fullscreen mode Exit fullscreen mode

ViewQueries type is a Record type like a “dictionary”, a collection of key-value pairs that stores data or logic. As we said our keys are Views (src\common\constants\uri.ts):

enum VIEW {
    DEFAULT = "",
    HOME = "home",
    PEOPLE = "people",
    PLANETS = "planets",
    STARSHIP = "starship",
    FILMS = "films",
    VEHICLES = "vehicles",
    SPECIES = "species",
    SEARCH = "search"
}
Enter fullscreen mode Exit fullscreen mode

And our values are a type I called ViewStateLogic (src\common\models\entities.ts)

type ViewStateLogic = {
    allQuery: () => APICall;
    byPageQuery: (
        page: number
    ) => APICall;
    byIdQuery: (
        id: number
    ) => APICall;
    searchQuery: (stringParam: string) => APICall;
    updateFn: (data: UpdatePayload<AnyBusinessEntity>) => Action;
}
Enter fullscreen mode Exit fullscreen mode

It’s a simple type that works as our interface and in almost every case is an APICall type I declared:

type APICall = Promise<SwapiResponse<AnyBusinessEntity>>
Enter fullscreen mode Exit fullscreen mode

A promise of a SwapiResponse type, an abstraction that keeps the structure of the response our API delivers and allows us to reuse it with generic definition of the entity we are retrieving which could be a single one or an array of it (src\common\models\entities.ts).

type SwapiResponse<T> = {
    count: number,
    next: string | null,
    previous: string | null,
    results?: T[] | T
}
Enter fullscreen mode Exit fullscreen mode

The generic entity we could retrieve is a AnyBusinessEntity, an alternative type that encapsulates all entities referred to our business logic, this way (src\common\models\entities.ts):

type AnyBusinessEntity = People | Specie | Starship | Planet | Vehicle | Film;
Enter fullscreen mode Exit fullscreen mode

There is one signature of each object value of the queryByView object that is not an API call.

Is the dispatch that allows to send the retrieved values to our state.

I decided to include the signature of the corresponding dispatch function in each case to make the development faster and easier.

The main concern of this object is to integrate the logic thru signatures that calls definitions correctly splitted.

I will resume this subject in our next title regarding state management.

We explained the object literals which is one of our business logic’s cornerstones so far.

But it is a static object, a machine that waits for its operator. Then is when the custom hooks who handles it (UseQueryByView) comes into action.

I divided it on two parts: the API Calls and the dispatch functions, like this (src\hooks\query\UseQueryByView.tsx):

  // API CALLS
  const allQuery = (viewScene: VIEW) => {
    return queryByView[viewScene]?.allQuery();
  };

  const byPageQuery = (viewScene: VIEW, page: number) => {
    return queryByView[viewScene]?.byPageQuery(page);
  };

  const byIdQuery = (viewScene: VIEW, id: number) => {
    return queryByView[viewScene]?.byIdQuery(id);
  };

  const searchQuery = (viewScene: VIEW, stringParam: string) => {
    return queryByView[viewScene]?.searchQuery(stringParam);
  };
Enter fullscreen mode Exit fullscreen mode

All those functions receive a VIEW as a parameter, that value permits to dynamically compute the key that matches on our queryByView object.

We need and optional chaining of the property inside de object value we access when computed value matches to avoid any error.

May seems tricker than needed but keeping everything dynamic will help us to make our UI components extremely reusables and with the less updates as possible.

Ideally I want to use one single template that dynamically updates reacting to UI events.

Our target is to have a set of queries automatically triggered and/or available to do so as a response to user behaviour depending on the screen we are navigating, nothing else.

Second part of the UseByQuery hook gathers the dispatch functions. This is crucial because the behaviour of our app concerning the relationship with API depends on state changes:

  // TO BE DISPATCHED
  const updateDispatch = (
    viewScene: VIEW,
    data: UpdatePayload<AnyBusinessEntity>
  ) => {
    return queryByView[viewScene]?.updateFn(data) as Action;
  };
Enter fullscreen mode Exit fullscreen mode

I will resume the explanation of the three last functions. They are stored in QueryActions object which is our next title’s subject.

The first one called updateDispatch receives the VIEW to navigate the object literals queryByView, but also structures a payload data to be stored on our state dynamically thru the UpdatePayload type.

When the VIEW matches the key in the object literals triggers the specific update function and sends the payload structured by this type with generics again to reuse code (src\common\models\entities.ts):

type UpdatePayload<T> = {
    currentPage: number,
    nextPage: number,
    prevPage: number,
    results: T[],
    nextUri: string | null,
    prevUri: string | null
}

Enter fullscreen mode Exit fullscreen mode

I recommend you following the same steps of this and the incoming titles using breakpoints in your browser.

Depurating functioning code will make things clearer than any explanation by written.

All the abstraction and dynamic approach appears to be too much effort but keep in mind that all this is oriented to make scalability a piece of cake.

It is hard to design but easy to make securely and consistently grow. The basis of a project is key to take the most advantage of available resources on the future.

To be continued on Part II

Top comments (0)