Need to fill the gap in your tech team?

Rapidly become your extended team or build a product from scratch. Top-notch engineering solutions by Uinno.

learn more
Insights

Frontend architecture of an application - myth or reality

Dmitry Braginets
Development Team Lead
  • Dmitry Braginets
  • 15 min read
main
table of

content

You may have never thought about the possible architectural issues in software development, but they do exist. Probably, frontend engineers still have a bias towards technological solutions, but not the architectural ones.

What is at stake?

For instance, over the Internet, be it posts in social media like Twitter or articles on content platforms like Medium, there are mostly discussions about new libraries, frameworks, and custom software solutions.

However, you can almost never find a debate about architectural issues. It seems that these discussions are a direct reflection of the code that app developers write. There are so many product development projects with really cool and sophisticated custom software solutions. But these web projects had to be expanded or modified not because of the existing architecture, but in spite of it.

The сore сhallenge

As it happens, for a long period of time the frontend part of the product development has been treated absolutely frivolously. At the same time, frontend developers were considered far from programming. While honored and experienced backend developers were creating patterns and solving architectural problems, frontend specialists riveted jQuery-flavored forms. That seems to be the main problem.

But times change. After Google released Gmail, which, in fact, is the prototype of web applications, everyone in the business sphere has begun to wish for the same technology solutions. That was the beginning. Angular, React, Vue - those are just the most popular technologies among web developers. The complexity of web applications grew year by year. Guess, who had to bring all this to life?

That's right - yesterday's undervalued frontend specialists or so-called webmasters. Those who had a distant understanding of an "architecture" word. Those who rather associated it with ancient Greek temples.

The future frontend engineers followed the path that respected backend developers had trodden back, probably, in the 80s and 90s of the last century. Loads of mistakes have been made so far and there are so many more of them in the future.
The second, but no less important part is that currently, frontend development has become a "gateway" to the IT world. The demand for Javascript developers is simply daunting. It greatly helps everyone to open the door to the alluring world of smoothies, MacBooks, and gyro scooters right from their feet.

This is neither good nor bad. These are the realities of the current technology market. The bad news is that it is difficult to educate such a large number of people. For some software development companies educating staff is not profitable at all. The main point for them is to witness how the oars rhythmically plunge into the abyss...

As a result, we get millions of code lines that work somehow or other, but groans heart-rendingly under the weight of technical debt. And these approaches migrate from one web project to another one.

What is frontend architecture?

Surely, it is easier to cite the architecture definition from some books written by respected authors or from Wikipedia. But in order to simplify the understanding, here is a short wording of what architecture stands for.

Architecture is how the parts, components, or modules of a program interact with each other.

For example, you have a very real single-page web application. What does "real" mean here? The fact that it is all written in one single file of 25-35 thousand code lines with many global variables that are mutated from a thousand places. Well, it turns out to be architecture. Even though it's more like a huge stone block.

A simple example of a frontend architecture

Let's consider the examples that are mostly far-fetched. The idea is to simplify the understanding and somehow fit all this into a couple of code snippets. Because if you take examples from real web projects, the article may be too long and too detailed.

Below you will see a simple example of a task list widget (just another to-do list app). You can find it in similar interpretations on the Internet. To concentrate on architecture and avoid the details of implementation, the code sample consists of pseudocode.

For clearance, all code examples in the article were developed using React framework. However, this approach is easily portable to Vue, especially using the composition API.

const TodoListComponent = ({ todoListId }) => {
  const [todoList, setTodoList] = useState([]);s
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);

  const todoCreateHandler = useCallback((todo) => {
    axios
      .post("/todo", {
        // post body
      })
      .then()
      .catch();
  },[]);

  // handle API call to update todo by id
  const todoItemCheckHandler = useCallback(/** ... */);

  useEffect(() => {
    if (!isModal) {
      axios.get(`/todo-list/${todoListId}`).then((res) => {
        const data = lodash.get(res, "data");
        setTodoList(data);
      });
    }
  }, [todoListId]);

  return (
    <div>
      <h2>{todoList.title}</h2>
      <ul>
        {todoList.todos.map(() => {
          /**
            * Here we implement the TodoItemComponent functionality
            * and handle todoItem check/uncheck
          */
        })}
        <TodoItemCreateComponent onCreate={todoCreateHandler} />
      </ul>
    </div>
  );
};

As you can see, the tutorial is similar to the ones you already found in Medium, dev.to, etc. We receive data using axios in a direct component. Then lodash allows us to get data from the API response body. Now, we can save it to a local state and map it to JSX.

As a result, it all will work. Then what is the issue, you ask? The thing is that we have nailed all possible dependencies to the component. The component knows how, with what, and where we get the data from. We conduct manipulations with it and then directly map the structure obtained from the backend into our component. We also need to immediately handle the loading, fetched, or error states.

Let's just imagine a basic case. Backend developers have changed the structure of the response and we need to modify the component. If they changed the field names, then the JSX or Template has to be changed as well. If we decide to use redux, vuex, or any other state-manager, then again we will have to rewrite the component. Let's see what the options are in this case step-by-step.

A better scenario

To improve the solution, we have to do the following:

  • Create an APIClient that will use axios as a transportation method;
  • Impose utilities into a separate module.
const TodoListComponent = ({todoListId}) => {
  const [todoList, setTodoList] = useState([]);
  const todoCreateHandler = useCallback((todo) => {
    apiClient.createTodo(todoListId, todo).then().catch()
  },[])

  // handle API call to update todo by id
  const todoItemCheckHandler = useCallback(/** ... */);

  useEffect(() => {
    // Here we "hide" the axios and lodash into the separate modules
    apiClient.getTodoList(todoListId)
      .then(setTodoList);
  }, [todoListId]);

  return (
     <div>
       <h2>{todoList.title}</h2>
       <ul>
         {todoList.todos.map(() =>{
           /**
           * Here we implement the TodoItemComponent functionality
           * and handle todoItem check/uncheck
           */
         })}
         <TodoItemCreateComponent onCreate={todoCreateHandler}/>
       </ul>
     </div>
  );
};

Now our component does not know that we are using axios and lodash. Also, it doesn't know that we are aiming at the specific endpoint. But we still directly utilize the data from the backend in the display.

Getting data about the web app

Here's what more we can do:

  • Use something similar to the Repository pattern to encapsulate getting data about the TodoList. For instance, let's name it TodoListHttpRepository;
  • If we use React or Vue3, then we can make a reusable module with the hooks or composition API. This module will leverage TodoListHttpRepository to receive data that can help to create an object or a class instance. The last one will follow the contract.
class TodoList {
  constructor(todoList) {
    this.title = todoList.listTitle;
    this.todos = todoList.listTodos;
  }
}

class TodoListHttpRepository {
  // Here we will pass the axios based api client
  constructor(transport) {
    this.transport = transport;
  }

  async getById(id) {
    const res = this.transport.get(`/todo-list/${id}`);

    return new TodoList(res);
  }

  async create(todoList) {
    /** */
  }

  async updateById(id, todoList) {
    /** */
  }

  async delete(id) {
    /** */
  }
}

const todoListRepo = new TodoListRepository(apiClient);

const useTodoList = (todoListId) => {
  const [todoList, setTodoList] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    todoListRepo
      .getById(todoListId)
      .then(setTodoList)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [todoListId]);

  return { data: todoList, error, loading };
};

const TodoListComponent = ({ todoListId }) => {
  const { data, loading, error } = useTodoList(todoListId);
  const todoItemCheckHandler = useTodoUpdate(todoListId);
  const todoCreateHandler = useTodoCreate(todoListId);

  return (
    <div>
      <h2>{data.title}</h2>
      <ul>
        {data.todos.map(() => {
          /**
            * Here we implement the TodoItemComponent functionality
            * and handle todoItem check/uncheck
            */
        })}
        <TodoItemCreateComponent onCreate={todoCreateHandler} />
      </ul>
    </div>
  );
};

Now our component does not directly depend on what kind of response the API will send us. Here you may even notice the last letter of the SOLID acronym - Dependency Inversion.

Also, our component has little or no knowledge of where it receives data from. In fact, there is a huge value in a component that doesn't care where this data comes from - localstorage, http, state-manager, etc.

You may even notice that useTodoList seems to be very similar to react-query (swr, etc.) template, however, that's a topic for a separate article.

The next step

Now let's physically (at the module level) separate the view (JSX, Template, etc.) from the logic.

const useTodoListComponentState = ({ todoListId }) => {
  const { data, loading, error } = useTodoList(todoListId);
  const updateHandler = useTodoUpdate(todoListId);
  const createHandler = useTodoCreate(todoListId);

  return { data, loading, error, createHandler, updateHandler };
};

const TodoListComponent = ({ todoListId }) => {
  const { data, loading, error, createHandler, updateHandler } =
    useTodoListComponentState(todoListId);

  return (
    <div>
      <h2>{data.title}</h2>
      <ul>
        {data.todos.map(() => {
          /**
            * Here we implement the TodoItemComponent functionality
            * and handle todoItem check/uncheck
            */
        })}
        <TodoItemCreateComponent onCreate={todoCreateHandler} />
      </ul>
    </div>
  );
};

What for, you ask? The answer will be revealed a little bit later.

Suddenly, a new requirement

So what does that bring us?

You probably think that now instead of a single file, it is necessary to write a new module for each and every need. Then upgrade it with abstractions. Indeed, to receive an architecture that is open to extensions and resistant to change, we have to split our code and rely on contracts between layers. It doesn't matter if it involves a backend or a frontend development.

Now comes the fun part. Imagine a so-often situation when a client comes to a web developer and says: "The app is awesome. It looks amazing. There's just one thing - yesterday I had a brilliant idea while I was taking a bath with champagne...". Well, you get the idea of what comes next.

"To make a UX design even better, you need to let the user create a TodoList that already includes tasks. What's more important is that it all needs to be performed in a modal dialog box”. Well, we all love modal dialog boxes, don't we?...

“When a user clicks a button, a modal dialog box appears. There is an ability to enter the list name and fill in the tasks. A user can add, change or delete tasks whenever there's a need. All modifications can be saved in the database once the user clicks the Create button”. Wait for it...

"I mean, everything is already working perfectly. Just two minutes and this whole functionality gets integrated into a modal dialog box". How much code do you think we would have to rewrite in case of refactoring? Probably, our component would look like a set of several conditional statements and some kind of selection logic. You will need a local state or a certain state-manager to store the intermediate data state until the user clicks the button.

If you combine all this in one component, it will turn out not as beautiful and simple as it was at the very beginning. Any change of the existing code increases the chance that something will fall off or may be broken. Moreover, our TodoList is not covered with tests. However, this is a totally different story.

const TodoListComponent = ({ todoListId, isModal }) => {
  const [todoList, setTodoList] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);

  const todoCreateHandler = useCallback((todo) => {
    axios
      .post("/todo", {
        // post body
      })
      .then()
      .catch();
  });

  // handle API call to update todo by id
  const todoItemCheckHandler = useCallback(/** ... */);

  const todoListRedux = useSelector((state) => selectTodoListById(todoListId));
  const dispatch = useDispatch();

  useEffect(() => {
    if (!isModal) {
      axios.get(`/todo-list/${todoListId}`).then((res) => {
        const data = lodash.get(res, "data");
        setTodoList(data);
      });
    }
  }, [todoListId]);

  const data = isModal ? todoListRedux : todoList;
  const createHandler = isModal
    ? todoCreateHandler
    : (todo) => dispatch(createTodoAction(todo));
  // Here could be the other conditions

  return (
    <div>
      <h2>{data.title}</h2>
      <ul>
        {data.todos.map(() => {
          /**
            * Here we implement the TodoItemComponent functionality
            * and handle todoItem check/uncheck
            */
        })}
        <TodoItemCreateComponent onCreate={createHandler} />
      </ul>
    </div>
  );
};

After refactoring, we clearly u nderstand that there is nothing to modify. All we have to do is add.

We can create a separate module that will handle the state of our TodoList but stay in the modal box. Now we just need to select the required module depending on where our component is located. The old code did not change. The new one was added easily and simply since you only need to comply with the contract between the layers.

const hooks = {
  "todo-list-page": useTodoListComponentState,
};

const useHook = ({ context, ...rest }) => {
  return hooks[context](rest);
};

const TodoListComponent = ({ todoListId, context }) => {
  const { data, loading, error, createHandler, updateHandler } = useHook({
    todoListId,
    context,
  });

  return (
    <div>
      <h2>{data.title}</h2>
      <ul>
        {data.todos.map(() => {
          /**
            * Here we implement the TodoItemComponent functionality
            * and handle todoItem check/uncheck
            */
        })}
        <TodoItemCreateComponent onCreate={todoCreateHandler} />
      </ul>
    </div>
  );
};

const hooks = {
  "todo-list-page": useTodoListComponentState,
  "create-todo-list-modal": useTodoListCreateModalState,
};

const useHook = ({ context, ...rest }) => {
  return hooks[context](rest);
};

const TodoListComponent = ({ todoListId, context }) => {
  const { data, loading, error, createHandler, updateHandler } = useHook({
    todoListId,
    context,
  });

  return (
    <div>
      <h2>{data.title}</h2>
      <ul>
        {data.todos.map(() => {
          /**
            * Here we implement the TodoItemComponent functionality
            * and handle todoItem check/uncheck
            */
        })}
        <TodoItemCreateComponent onCreate={todoCreateHandler} />
      </ul>
    </div>
  );
};

That's exactly why we separated the display from the logic. The same mapping can have multiple data sources and multiple execution contexts. In no way, it is an invention of a fevered mind. We have developed a web product where the same widget was used in four different places while having two different data sources. And this is just the tip of the iceberg.

Conclusions

This approach to frontend architecture is not an invention nor a silver bullet. Not by a long shot. It just uncovers the importance of good frontend architecture. The one that will help web app developers to add new features to applications or change the old ones per the client’s request.

It leads to less time-to-market time, which, in turn, reduces app development costs and makes customers much happier.

Someone might say that in order to get away from contexts, you can use HOC (aka Container Components). And that is completely correct. Somebody may suggest a bunch of other solutions and the truth will be on their side too.

Let us emphasize that the described approach is not for a copy-paste attitude. You can not just replicate it in your web project. Instead, you can think about its importance and avoid certain patterns that may ruin all your aspirations for the future of your web product growth.

Require a consultation on the frontend architecture of a web app? Get in touch!