Application of SOLID principles in the development of React-applications

https://blog.usejournal.com/how-to-apply-solid-principles-in-react-applications-6c964091a982
  • Transfer
We recently published material on the SOLID methodology. Today we bring to your attention the translation of an article that is devoted to applying the SOLID principles in developing applications using the popular React library. The author of the article says that here, for the sake of brevity, he does not show the full implementation of some components.

image



The principle of sole responsibility (S)


The Single Responsibility Principle tells us that the module should have one and only one reason for change.

Imagine that we are developing an application that displays a list of users in a table. Here is the component code App:

class App extends Component {
    state = {
        users: [{name: 'Jim', surname: 'Smith', age: 33}]
    };
    componentDidMount() {
        this.fetchUsers();
    }
    async fetchUsers() {
        const response = await fetch('http://totallyhardcodedurl.com/users');
        const users = await response.json();
        this.setState({users});
    }
    render() {
        return (
            <div className="App">
                <header className="App-header">
                  // тут опущено огромное количество кода заголовка
                </header>
                <table>
                    <thead>
                        <tr>
                            <th>First name</th>
                            <th>Last name</th>
                            <th>Age</th>
                        </tr>
                    </thead>
                    <tbody>
                        {this.state.users.map((user, index) => (
                            <tr key={index}>
                                <td><inputvalue={user.name} onChange={/* update name in the state */}/></td>
                                <td><inputvalue={user.surname} onChange={/* update surname in the state*/}/></td>
                                <td><inputvalue={user.age} onChange={/* update age in the state */}/></td>
                            </tr>
                        ))}
                    </tbody>
                </table>
                <button onClick={() => this.saveUsersOnTheBackend()}>Save</button>
            </div>
        );
    }
    saveUsersOnTheBackend(row) {
        fetch('http://totallyhardcodedurl.com/users', {
            method: "POST",
            body: JSON.stringify(this.state.users),
        })
    }
}

We have a component in which the list of users is stored. We download this list via HTTP from some server, the list is editable. Our component violates the principle of sole responsibility, as it has more than one reason for change.

In particular, I can see four reasons for changing a component. Namely, the component changes in the following cases:

  • Every time you need to change the title of the application.
  • Every time you need to add a new component to the application (page footer, for example).
  • Every time when you need to change the mechanism for downloading data about users, for example - the server address or protocol.
  • Every time you need to change a table (for example, change the formatting of columns or perform some other action like this).

How to solve these problems? It is necessary, after the reasons for changing a component are identified, to try to eliminate them, to derive them from the source component, creating suitable abstractions (components or functions) for each such reason.

Let's solve the problems of our component by Apprefactoring it. Its code, after splitting it into several components, will look like this:

classAppextendsComponent{
    render() {
        return (
            <div className="App">
                <Header/>
                <UserList/>
            </div>
        );
    }
}

Now, if we need to change the title, we change the component Header, and if we need to add a new component to the application, we change the component App. Here we have solved problems # 1 (changing the header of the application) and problem # 2 (adding a new component to the application). This is done by moving the corresponding logic from the component Appto the new components.

Now we are going to solve problems №3 and №4, creating a class UserList. Here is his code:

classUserListextendsComponent{
  
    static propTypes = {
        fetchUsers: PropTypes.func.isRequired,
        saveUsers: PropTypes.func.isRequired
    };
    state = {
        users: [{name: 'Jim', surname: 'Smith', age: 33}]
    };
    componentDidMount() {
        const users = this.props.fetchUsers();
        this.setState({users});
    }
    render() {
        return (
            <div>
                <UserTable users={this.state.users} onUserChange={(user) => this.updateUser(user)}/>
                <button onClick={() => this.saveUsers()}>Save</button>
            </div>
        );
    }
    updateUser(user) {
      // обновить сведения о пользователе в состоянии
    }
    saveUsers(row) {
        this.props.saveUsers(this.state.users);
    }
}

UserList- this is our new component container. Thanks to him, we solved problem number 3 (changing the user loading mechanism) by creating function-properties fetchUserand saveUser. As a result, now, when we need to change the link used to load the list of users, we turn to the corresponding function and make changes to it.

The last problem that we have at number 4 (changing the table that displays the list of users) has been solved by introducing a presentation component into the project UserTable, which encapsulates the formation of HTML code and the styling of the table with users.

The principle of openness-closure (O)


The Open Closed Principle states that software entities (classes, modules, functions) must be open for expansion, but not for modification.

If you look at the component UserListdescribed above, you will notice that if you need to display a list of users in a different format, we will have to modify the method of renderthis component. This is a violation of the principle of openness-closeness.

To bring the program into compliance with this principle, you can use the composition of components .

Take a look at the code of the component UserListthat has been refactored:

export classUserListextendsComponent{
    static propTypes = {
        fetchUsers: PropTypes.func.isRequired,
        saveUsers: PropTypes.func.isRequired
    };
    state = {
        users: [{id: 1, name: 'Jim', surname: 'Smith', age: 33}]
    };
    componentDidMount() {
        const users = this.props.fetchUsers();
        this.setState({users});
    }
    render() {
        return (
            <div>
                {this.props.children({
                    users: this.state.users,
                    saveUsers: this.saveUsers,
                    onUserChange: this.onUserChange
                })}
            </div>
        );
    }
    saveUsers = () => {
        this.props.saveUsers(this.state.users);
    };
    onUserChange = (user) => {
        // обновить сведения о пользователе в состоянии
    };
}

The component UserList, as a result of the modification, turned out to be open for expansion, since it displays the child components, which facilitates changing its behavior. This component is closed for modification, as all changes are performed in separate components. We can even deploy these components independently.

Now let's look at how, using the new component, a list of users is displayed.

export classPopulatedUserListextendsComponent{
    render() {
        return (
            <div>
                <UserList>{
                    ({users}) => {
                        return <ul>
                            {users.map((user, index) => <li key={index}>{user.id}: {user.name} {user.surname}</li>)}
                        </ul>
                    }
                }
                </UserList>
            </div>
        );
    }
}

Here we expand the behavior of the component UserListby creating a new component that knows how to display a list of users. We can even download more detailed information about each of the users in this new component, without touching the component UserList, and this is precisely the purpose of refactoring this component.

Barbara Liskov substitution principle (L)


The substitution principle of Barbara Liskov (Liskov Substitution Principle) indicates that the objects in the programs must be replaced by instances of their subtypes without disrupting the correctness of the program.

If this definition seems to you to be too loosely formulated - that’s a stricter version of it.


Barbara Liskov's principle of substitution: if something looks like a duck and quacks like a duck, but needs batteries - probably the wrong abstraction is chosen.

Take a look at the following example:

classUser{
  constructor(roles) {
    this.roles = roles;
  }
  
  getRoles() {
    returnthis.roles;
  }
}
classAdminUserextendsUser{}
const ordinaryUser = new User(['moderator']);
const adminUser = new AdminUser({role: 'moderator'},{role: 'admin'});
functionshowUserRoles(user) {
  const roles = user.getRoles();
  roles.forEach((role) =>console.log(role));
}
showUserRoles(ordinaryUser);
showUserRoles(adminUser);

We have a class Userwhose constructor accepts user roles. Based on this class, we create a class AdminUser. After that, we created a simple function showUserRolesthat takes a type object as Usera parameter and displays all the roles assigned to the user to the console.

We call this function, passing it objects ordinaryUserand adminUserthen we encounter an error.


Error

What happened? The class objectAdminUseris similar to the class objectUser. He certainly "quacks" asUserhe has the same methods as inUser. The problem is the "battery". The fact is that when creating an objectadminUser, we passed a couple of objects to it, not an array.

The principle of substitution has been violated here, since the functionshowUserRolesmust work correctly with objects of a classUserand with objects created on the basis of the heir classes of this class.

Fixing this problem is easy - just pass theAdminUserarrayto the constructorinstead of the objects:

const ordinaryUser = new User(['moderator']);
const adminUser = new AdminUser(['moderator','admin']);

Interface separation principle (I)


Interface Segregation Principle indicates that programs should not depend on what they do not need.

This principle is especially relevant in languages ​​with static typing, in which dependencies are explicitly given by interfaces.

Consider an example:

classUserTableextendsComponent{
    ...
    
    render() {
        const user = {id: 1, name: 'Thomas', surname: 'Foobar', age: 33};
        return (
            <div>
                ...
                  <UserRow user={user}/>
                ...
            </div>
        );
    }
   
    ...
}
classUserRowextendsComponent{
    static propTypes = {
        user: PropTypes.object.isRequired,
    };
    render() {
        return (
            <tr>
                <td>Id: {this.props.user.id}</td>
                <td>Name: {this.props.user.name}</td>
            </tr>
        )
    }
}

The component UserTablerenders the component UserRow, passing to it, in properties, an object with complete information about the user. If we analyze the component code UserRow, it will turn out that it depends on the object containing all the information about the user, but he only needs the properties idand name.

If you write a test for this component and at the same time use TypeScript or Flow, you will have to create an imitation of the object userwith all its properties, otherwise the compiler will generate an error.

At first glance, this does not seem to be a problem if you use pure JavaScript, but if TypeScript ever lodges in your code, this will suddenly lead to test failure due to the need to assign all interface properties, even if only some of them are used.

Whatever it was, but the program, which satisfies the principle of interface separation, turns out to be more understandable.

classUserTableextendsComponent{
    ...
    
    render() {
        const user = {id: 1, name: 'Thomas', surname: 'Foobar', age: 33};
        return (
            <div>
                ...
                  <UserRow id={user.id} name={user.name}/>
                ...
            </div>
        );
    }
   
    ...
}
classUserRowextendsComponent{
    static propTypes = {
        id: PropTypes.number.isRequired,
        name: PropTypes.string.isRequired,
    };
    render() {
        return (
            <tr>
                <td>Id: {this.props.id}</td>
                <td>Name: {this.props.name}</td>
            </tr>
        )
    }
}

Remember that this principle applies not only to the types of properties passed to the components.

Principle of dependency inversion (D)


The principle of dependency inversion (Dependency Inversion Principle) tells us that the object of the dependency must be an abstraction, and not something concrete.

Consider the following example:

classAppextendsComponent{
  
  ...
  async fetchUsers() {
    const users = await fetch('http://totallyhardcodedurl.com/stupid');
    this.setState({users});
  }
  ...
}

If we analyze this code, it becomes clear that the component Appdepends on the global function fetch. If we describe the relationship of these entities in the UML language, we get the following diagram.


Component-Function Relationships A

high-level module should not depend on low-level concrete implementations of anything. It must depend on abstraction.

The componentAppdoes not need to know how to load information about users. In order to solve this problem, we need to invert the dependencies between the componentAppand the functionfetch. Below is a UML diagram illustrating this.


Inversion of dependencies

Here is the implementation of this mechanism.

classAppextendsComponent{
  
    static propTypes = {
        fetchUsers: PropTypes.func.isRequired,
        saveUsers: PropTypes.func.isRequired
    };
    ...
    
    componentDidMount() {
        const users = this.props.fetchUsers();
        this.setState({users});
    }
    ...
}

Now we can say that the component is distinguished by its weak connectivity, since it does not have information about which particular protocol we are using - HTTP, SOAP, or some other. The component does not care at all.

Adherence to the principle of dependency inversion extends our ability to work with code, since we can very easily change the data loading mechanism, and the component Appwill not change at all.

In addition, it simplifies testing, as it is easy to create a function that simulates the function of loading data.

Results


By investing time in writing quality code, you will earn the gratitude of your colleagues and yourself when, in the future, you will have to come across this code again. Implementing SOLID principles in the development of React applications is a worthwhile investment of time.

Dear readers! Do you use the principles of SOLID when developing React-applications?


Also popular now: