Web UI Architecture: A Wooden Past, A Strange Present, and a Bright Future

    image

    The modern community of developers is now more than ever subject to fashion and trends, and this is especially true for the world of front-end development. Our frameworks and new practices are the main value, and most of the CVs, vacancies, and conference programs consist of listing them. And although the development of ideas and tools is not negative in itself, due to the constant desire of developers to follow elusive trends, we began to forget about the importance of general theoretical knowledge about application architecture.

    The prevalence of tuning over knowledge of theory and best practices has led to the fact that most new projects today have an extremely low level of maintainability, thereby creating significant inconvenience for developers (the consistently high complexity of studying and modifying the code) and for customers (low rates and high development cost).

    In order to at least somehow influence the current situation, today I would like to tell you about what a good architecture is, how it is applicable to web interfaces, and most importantly, how it evolves over time.

    NB: As examples in the article, only those frameworks that the author directly dealt with will be used, and significant attention will be paid to React and Redux here. But, despite this, many of the ideas and principles described here are general in nature and can be more or less successfully projected onto other interface development technologies.

    Architecture for Dummies


    To begin, let's deal with the term itself. In simple words, the architecture of any system is the definition of its components and the scheme of interaction between them. This is a kind of conceptual foundation on top of which implementation will later be built.

    The task of the architecture is to satisfy the external requirements for the designed system. These requirements vary from project to project and can be quite specific, but in the general case they are to facilitate the processes of modification and expansion of the developed solutions.

    As for the quality of architecture, it is usually expressed in the following properties:

    - Accompaniment: the already mentioned predisposition of the system to study and modification (the difficulty of detecting and correcting errors, expanding functionality, adapting the solution to another environment or conditions)
    - Interchangeability : the ability to change the implementation of any element of the system without affecting other elements
    - Testability : the ability to verify the correct operation of the element ( the ability to control the element and monitor its condition)
    - Portability : the ability to reuse the element in other systems
    - Usability : overall system usability for end-user use

    Separate mention is also made of one of the key principles of building a quality architecture: the principle of separation of concerns . It consists in the fact that any element of the system should be responsible exclusively for one single task (applied, incidentally, to the application code: see single responsibility principle ).

    Now that we have an idea of ​​the concept of architecture, let's see what architectural design patterns can offer us in the context of interfaces.

    The three most important words


    Одним из самых известных паттернов разработки интерфейсов является MVC (Model-View-Controller), ключевой концепцией которого является разделение логики интерфейса на три отдельные части:

    1. Model — отвечает за получение, хранение и обработку данных
    2. View — отвечает за визуализацию данных
    3. Controller — осуществляет управление Model и View

    Данный паттерн также включает в себя описание схемы взаимодействия между ними, но здесь эта информация будет опущена в связи с тем, что спустя определенное время широкой общественности была представлена улучшенная модификация этого паттерна под названием MVP (Model-View-Presenter), которая эту исходную схему взаимодействия значительно упрощала:

    image

    Since we are talking specifically about web interfaces, we used another rather important element that usually accompanies the implementation of these patterns - a router. Its task is to read the URL and call the presenters associated with it.

    The above scheme works as follows:

    1. The router reads the URL and calls the associated Presenter
    2-5. Presenter contacts the Model and receives the necessary data from it
    6. Presenter transfers the data from the Model to the View, which implements them
    7. When interacting with the user interface, the View notifies the Presenter about this, which returns us to the second point

    As practice has shown, MVC and MVP are not an ideal and universal architecture, but they still do one very important thing - they indicate three key areas of responsibility, without which no interface can be implemented in one form or another.

    NB: By and large, the concepts of Controller and Presenter mean the same thing, and the difference in their name is necessary only to differentiate the mentioned patterns, which differ only in the implementation of communications .

    MVC and server rendering


    Despite the fact that MVC is a pattern for implementing a client, it finds its application on the server as well. Moreover, it is in the context of the server that it is easiest to demonstrate the principles of its operation.

    In cases when we are dealing with classic information sites where the web server's task is to generate HTML pages for the user, MVC also allows us to organize a fairly concise application architecture:

    - Router reads data from the received HTTP request (GET / user -profile / 1) and calls the associated Controller (UsersController.getProfilePage (1))
    - the Controller calls the Model to get the necessary information from the database (UsersModel.get (1))
    - The controller passes the received data to the View(View.render ('users / profile', user)) and gets HTML markup from it, which is passed back to the client.

    In this case, View is usually implemented as follows:

    image

    const templates = {
      'users/profile': `
        
      `
    };
    class View {
      render(templateName, data) {
        const htmlMarkup = TemplateEngine.render(templates[templateName], data);
        return htmlMarkup;
      }
    }
    

    NB: The code above is intentionally simplified for use as an example. In real projects, templates are exported to separate files and pass through the compilation stage before use (see Handlebars.compile () or _.template () ).

    Here the so-called template engines are used, which provide us with tools for convenient description of text templates and mechanisms for substituting real data in them.

    Such an approach to the implementation of View not only demonstrates an ideal separation of responsibilities, but also provides a high degree of testability: to check the correctness of the display, it is enough for us to compare the reference line with the line that we got from the template engine.

    Thus, using MVC, we get an almost perfect architecture, where each of its elements has a very specific purpose, minimal connectivity, and also has a high level of testability and portability.

    As for the approach with the generation of HTML markup using server tools, due to the low UX, this approach gradually began to be replaced by SPA.

    Backbone and MVP


    One of the first frameworks to fully bring the display logic to the client was Backbone.js . The implementation of Router, Presenter and Model in it is fairly standard, but the new implementation of View deserves our attention:

    image

    const UserProfile = Backbone.View.extend({
      tagName: 'div',
      className: 'user-profile',
      events: {
        'click .button.edit':   'openEditDialog',
      },
      openEditDialog: function(event) {
        // ...
      },
      initialize: function() {
        this.listenTo(this.model, 'change', this.render);
      },
      template: _.template(`
        

    <%= name %>

    E-mail: <%= email %>

    Projects: <% _.each(projects, project => { %> <%= project.name %> <% }) %>

    `), render: function() { this.$el.html(this.template(this.model.attributes)); } });

    Obviously, the implementation of the mapping has become much more complicated - listening to events from the model and the DOM, as well as the logic of their processing, has been added to elementary standardization. Moreover, to display changes in the interface, it is highly desirable not to completely re-render the View, but to do finer work with specific DOM elements (usually using jQuery), which required writing a lot of additional code.

    Due to the general complication of the View implementation, its testing became more complicated - since now we are working directly with the DOM tree, for testing we need to use additional tools that provide or emulate the browser environment.

    And the problems with the new View implementation did not end there:

    In addition to the above, it is rather difficult to use nested in each other View. Over time, this problem was resolved with the help of Regions in Marionette.js , but before that, developers had to invent their own tricks to solve this rather simple and often arising problem.

    And the last one. The interfaces developed in this way were predisposed to data out of sync - since all models existed isolated at the level of different presenters, then when changing data in one part of the interface, they usually did not update in another.

    But, despite these problems, this approach turned out to be more than viable, and the previously mentioned development of Backbone in the form of Marionette It can still be successfully applied for the development of SPA.

    React and Void


    It's hard to believe, but at the time of its initial release, React.js caused a lot of skepticism among the developer community. This skepticism was so great that for a long time the following text was posted on the official website:

    Give It Five Minutes
    React challenges a lot of conventional wisdom, and at first glance some of the ideas may seem crazy.

    And this despite the fact that, unlike most of its competitors and predecessors, React was not a full-fledged framework and was just a small library to facilitate the display of data in the DOM:

    React is a JavaScript library for creating user interfaces by Facebook and Instagram. Many people choose to think of React as the V in MVC.

    The main concept that React offers us is the concept of a component, which, in fact, provides us with a new way to implement View:

    class User extends React.Component {
      handleEdit() {
        // ..
      }
      render() {
        const { name, email, projects } = this.props;
        return (
          

    {name}

    E-mail: {email}

    Projects: { projects.map(project => {project.name}) }

    ); } }

    React was incredibly enjoyable to use. Among its undeniable advantages were to this day remain:

    1) Declarability and reactivity . There is no longer any need to manually update the DOM when changing the displayed data.

    2) The composition of the components . Building and exploring the View tree has become a completely elementary action.

    But, unfortunately, React has a number of problems. One of the most important is the fact that React is not a full-fledged framework and, therefore, does not offer us any kind of application architecture or full-fledged tools for its implementation.

    Why is this written into flaws? Yes, because now React is the most popular solution for developing web applications ( proof, another proof , and one more proof ), it is an entry point for new front-end developers, but at the same time it does not offer and does not advocate any architecture or any approaches and best practices for building full-fledged applications. Moreover, he invents and promotes his own custom approaches like HOC or Hooks , which are not used outside of the React ecosystem. As a result, each React application solves typical problems in its own way, and usually does not do it in the most correct way.

    This problem can be demonstrated with the help of one of the most common errors of React developers, which consists in the abuse of components:

    If the only tool you have is a hammer, everything begins to look like a nail.

    With their help, developers solve a completely unthinkable range of tasks that go far beyond the scope of data visualization. Actually, with the help of components they implement absolutely everything - from media queries from CSS to routing .

    React and Redux


    Restoring order in the structure of React applications was greatly facilitated by the appearance and popularization of Redux . If React is a View from MVP, then Redux offered us a fairly convenient variation of Model.

    The main idea of ​​Redux is the transfer of data and the logic of working with them into a single centralized data warehouse - the so-called Store. This approach completely solves the problem of data duplication and desynchronization, which we talked about a little earlier, and also offers many other amenities, which, among other things, include the ease of studying the current state of the data in the application.

    Another equally important feature is the way of communication between the Store and other parts of the application. Instead of directly accessing the Store or its data, we are offered to use the so-called Actions (simple objects describing the event or command), which provide a weak level of loose coupling between the Store and the event source, thereby significantly increasing the degree of project maintainability. Thus, Redux not only forces developers to use more appropriate architectural approaches, but also allows you to take advantage of the various benefits of event sourcing- now in the debug process we can easily view the history of actions in the application, their impact on the data, and if necessary, all this information can be exported, which is also extremely useful when analyzing errors from production.

    The general scheme of the application using React / Redux can be represented as follows:

    image

    React components are still responsible for displaying data. Ideally, these components should be clean and functional, but if necessary, they may well have a local state and associated logic (for example, to implement hiding / displaying a specific element or basic preprocessing of a user action).

    When a user performs an action in the interface, the component simply calls the corresponding handler function, which it receives from the outside along with the data for display.

    So-called container components act as Presenter for us - they are the ones who exercise control over the display components and their interaction with the data. They are created using the connect function , which extends the functionality of the component passed to it, adding a subscription to change data in the Store and letting us determine which data and event handlers should be passed to it.

    And if everything is clear with the data here (we just map data from the store to the expected “props”), then I would like to dwell on the event handlers in more detail - they not only send Actions to the Store, but may well contain additional logic for processing the event - for example, include branching, perform automatic redirects, and perform any other work specific to the presenter.

    Another important point regarding container components: due to the fact that they are created through the HOC, developers quite often describe display components and container components within a single module and export only the container. This is not the right approach, since for the possibility of testing and reusing the display component, it should be completely separated from the container and preferably taken out in a separate file.

    Well, the last thing that we have not yet considered is the Store. It serves us as a rather specific implementation of the Model and consists of several components: State (an object containing all our data), Middleware (a set of functions that preprocess all received Actions), Reducer (a function that modifies data in State) and some or a side effects handler responsible for executing asynchronous operations (accessing external systems, etc.).

    The most common issue here is the form of our State. Formally, Redux does not impose any restrictions on us and does not give recommendations as to what this object should be. Developers can store absolutely any data in it (including the state of forms and information from the router), this data can be of any type (it is not forbidden to store even functions and instances of objects) and have any level of nesting. In fact, this again leads to the fact that from project to project we get a completely different approach to using State, which once again causes some bewilderment.

    To begin with, we agree that we do not need to keep absolutely all application data in State - this is clearly indicated by the documentation. Although storing part of the data inside the state of components creates certain inconveniences when navigating through the history of actions during the debug process (the internal state of the components always remains unchanged), transferring this data to State creates even more difficulties - this significantly increases its size and requires the creation of even more Actions and reducers.

    As for storing any other local data in State, we usually deal with some general interface configuration, which is a set of key-value pairs. In this case, we can easily do with one simple object and a reducer for it.

    And if we are talking about storing data from external sources, then based on the fact that in the development of interfaces in the vast majority of cases we are dealing with classic CRUD, then for storing data from the server it makes sense to treat State as an RDBMS: the keys are the name resource, and behind them are stored arrays of loaded objects ( without nesting ) and optional information for them (for example, the total number of records on the server to create pagination). The general form of this data should be as uniform as possible - this will allow us to simplify the creation of reducers for each type of resource:

    const getModelReducer = modelName => (models = [], action) => {
      const isModelAction = modelActionTypes.includes(action.type);
      if (isModelAction && action.modelName === modelName) {
        switch (action.type) {
          case 'ADD_MODELS':
            return collection.add(action.models, models);
          case 'CHANGE_MODEL':
            return collection.change(action.model, models);
          case 'REMOVE_MODEL':
            return collection.remove(action.model, models);
          case 'RESET_STATE':
            return [];
        }
      }
      return models;
    };

    Well, another point that I would like to discuss in the context of using Redux is the implementation of side effects.

    First of all, completely forget about Redux Thunk - the transformation of Actions proposed by him into functions with side effects, although it is a working solution, but it mixes the basic concepts of our architecture and reduces its advantages to nothing. Redux Saga offers us a much more correct approach to implementing side effects , although there are some questions regarding its technical implementation.

    Next - try to unify as much as possible your side effects that access the server. Like the State form and reducers, we can almost always implement the logic of creating requests to the server using one single handler. For example, in the case of the RESTful API, this can be achieved by listening to generalized Actions like:

    { 
      type: 'CREATE_MODEL', 
      payload: { 
        model: 'reviews', 
        attributes: {
          title: '...',
          text: '...'
        }
      } 
    }
    

    ... and creating the same generalized HTTP requests on them:

    POST /api/reviews
    {
      title: '...',
      text: '...'
    }

    By consciously following all of the above tips, you can get, if not an ideal architecture, then at least close to it.

    Bright future


    The modern development of web interfaces has really taken a significant step forward, and now we are living in a time when a significant part of the main problems have already been solved one way or another. But this does not mean at all that in the future there will be no new revolutions.

    If you try to look into the future, then most likely we will see the following there:

    1. Component approach without JSX

    The concept of components has proved to be extremely successful, and, most likely, we will see their even greater popularization. But JSX itself can and must die. Yes, it is really quite convenient to use, but, nevertheless, it is neither a generally accepted standard nor a valid JS code. Libraries for implementing interfaces, no matter how good they are, should not invent new standards, which then have to be implemented over and over again in every possible development toolkit.

    2. State containers without Redux

    Using the centralized data warehouse proposed by Redux was also an extremely successful solution, and should become a kind of standard in interface development in the future, but its internal architecture and implementation may well undergo certain changes and simplifications.

    3. Increasing library interchangeability

    I believe that over time, the front-end developer community will realize the benefits of maximizing library interchangeability and will no longer lock itself in its small ecosystems. All components of applications - routers, state containers, etc. - they should be extremely universal, and their replacement should not require mass refactoring or rewriting the application from scratch.

    Why all this?


    If we try to summarize the information presented above and reduce it to a simpler and shorter form, then we will get some fairly general points:

    - For successful application development, knowledge of the language and framework is not enough, attention should be paid to general theoretical things: application architecture, best practices and patterns designing.

    “The only constant is change.” Tilling and development approaches will continue to change, so large and long-lived projects must pay appropriate attention to architecture - without it, introducing new tools and practices will be extremely difficult.

    And that's probably all for me. Many thanks to everyone who found the strength to read the article to the end. If you have any questions or comments, I invite you to comment.

    Also popular now: