ReactSmallTips: neat components
I have been working with React for 6 years now and I have to say, at the beginning, my components were really scary to look at. Not to mention how hard it was to maintain the code, fix bugs and add new features. After some time feeling bothered by this, I started to think about how to improve it, how I could turn my “monster” components into clean, small, concise, and flexible components.
As I was improving my coding skills, I noticed that most of the time we, developers, weren’t applying good coding practices in the components as we usually do in some backend code, for instance. I’ve learned some principles and good practices from books, online courses and also from my colleagues, but my components weren’t reflecting any of this knowledge. But, as the application starts to grow and acquire more features, more components, more complexity, not caring about the components code can lead to real pain in the near future.
That said, I’m going to show an example of some things I try to avoid and some practices I use to keep my components as neat as possible.
The following component is a HomePage that fetches a list of users from an API and renders it. The HomePage component also includes a select field, which can be used to sort the list of users by name or age and also to set the document.title (the page title) according to the selected value (e.g.: “Users by age | name”).
Along with the code, I added some comments to highlight some changes we can make to improve the component.
import React, { useState, useEffect } from 'react';
import { Iuser } from './types/IUser';
const HomePage = () => {
const [sortBy, setSortBy] = useState('name');
const [users, setUsers] = useState<Iuser[] | null>(null);
const [error, setError] = useState(false);
// extract complex hooks logic to a custom hook
useEffect(() => {
const fetchUsers = async () => {
try {
// extract input/output of data (e.g: API calls) to a separate file
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
setUsers(data);
} catch (error) {
console.error('Error fetching users:', error);
}
};
fetchUsers();
}, []);
const handleSortChange = (event: {
target: { value: React.SetStateAction<string> };
}) => {
setSortBy(event.target.value);
// extract browser API interaction
document.title = `Users by ${event.target.value}`;
};
// extract data processing for rendering.
const sortedUsers =
users &&
[...users].sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
} else if (sortBy === 'age') {
return a.age - b.age;
}
return 0;
});
// split the component into smaller and concise parts
// avoid in-jsx conditional rendering
return (
<div>
<h2>User List</h2>
<label htmlFor="sort">Sort by:</label>
<select id="sort" value={sortBy} onChange={handleSortChange}>
<option value="name">Name</option>
<option value="age">Age</option>
</select>
{!error && users && (
<ul>
{sortedUsers?.map((user) => (
<li key={user.id}>
<img src={user.avatar} alt={`Avatar of ${user.name}`} />
<div>
<h3>{user.name}</h3>
<p>{user.age} years old</p>
</div>
</li>
))}
</ul>
)}
{error && (
<div>
<p>Something went wrong</p>
</div>
)}
</div>
);
};
export default HomePage;
In this post I’m not going to delve into the use of third-party libraries to simplify things like API calls. Of course there are many libraries that we could use it, and if it makes sense in the context of your application, use them. The example here was done purely with React (with no libs) for the purpose of understanding how a component can go from something cumbersome and sometimes complex to something simpler, organized, and clean. But I would like to emphasize that sometimes the use of third-party libraries can bring even more complexity to your code, so be careful when adopting a library. If you choose to use a library, a good idea is to encapsulate it in a layer so that your components do not interact directly with it. Encapsulation can bring a lot of advantages such as abstraction, ease of replacement, testability, code organization and risk reduction.
Now let’s try to apply some of the tips left in the comments above.
Extract complex hooks logic to a custom hook
When React hooks were first introduced their main purpose was to reuse logic across components. Nowadays many React libraries provide their own hooks that we can simply import and use. If we take a look at those libraries’ hooks, we may notice that some of them contain a considerable amount of code, sometimes a bit complex. However, when we use them in components, it is quite straightforward: you just call the hook and receive its return value (if there is one). So, I would argue that to simplify components code, we can create our own custom hooks to handle the logic. Then, we can simply use them in the components, similar to how we use third-party hooks. Even if the logic isn’t reusable, extracting it from the component can simplify unit tests (you can test the logic and the component separately) and make your component less imperative. Plus, who knows, maybe later in another component you’ll notice the same pattern, so you can turn the created custom hook into a more generic one that’s reusable across components.
Taking the HomePage component as an example, let’s first create a UserService file that will be responsable for API calling and error handling
import { IUser, API_PATHS } from '../../shared';
import { USER_API_EXCEPTIONS } from './homeUtils';
class UserService{
async getUsers() {
try {
const response = await fetch(API_PATHS.GET_USERS);
if (response.ok) {
const users = (await response.json()) as IUser[];
if (!users.length) {
USER_API_EXCEPTIONS.throwEmptyException();
}
return users;
}
USER_API_EXCEPTIONS.throwFailedException();
} catch (error) {
USER_API_EXCEPTIONS.throwFailedException();
}
}
}
export default new UserService();
Now let’s create a custom hook that will be responsible for firing that API call and returning its result to the component as needed.
import { useEffect, useState } from 'react';
import UserService from '../UserService';
import { IUser } from '../../../shared';
export const useLoadUsers = () => {
const [users, setUsers] = useState<IUser[] | null>(null);
const [error, setError] = useState('');
useEffect(() => {
const fetchUsers = async () => {
try {
const users = await UserService.getUsers();
if (users) {
setUsers(users);
}
} catch (error) {
setError((error as Error).message);
}
};
fetchUsers();
}, []);
return {
users,
error,
};
};
The above hook extracts the data-fetching logic from the component. Now, the component doesn’t need to know how it’s done, it just needs to use it. The implementation above could be easily replaced to use a third-party library like React Query to achieve the same result, and your component wouldn’t know, since it will only interact with the custom hook useLoadUsers. The power of encapsulation!
Now let’s refactor our component to see where we got so far..
import React, { useState } from 'react';
import { useLoadUsers } from '../hooks/useLoadUsers';
const HomePage = () => {
const [sortBy, setSortBy] = useState('name');
// NO FETCH IMPLEMENTARION HERE
// JUST CALL THE CUSTOM HOOK
const { users, error } = useLoadUsers();
// CODE..
};
export default HomePage;
Extract data processing into a utility function outside of the component.
The list of users can be ordered by name or age, so in that case, we can move the sorting list logic from the component to a pure JS function. Let’s create that function outside of the component
import { IUser, TSortBy } from '../../shared';
export const sortUsers = (users: IUser[] | null, sortBy: TSortBy) => {
if (users?.length) {
return users.sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
} else if (sortBy === 'age') {
return a.age - b.age;
}
return 0;
});
}
return [];
};
Now let’s use the function in the component
import React, { useState } from 'react';
import { useLoadUsers } from '../hooks/useLoadUsers';
import { sortUsers } from '../homeUtils';
import { TSortBy } from '../../../types/TSortBy';
const HomePage = () => {
const [sortBy, setSortBy] = useState<TSortBy>('name');
// ...CODE
// NO DATA PROCESSING IMPLEMENTATION HERE
// JUST CALL THE FUNCTION AND GET ITS RETURN
const sortedUsers = sortUsers(users, sortBy);
// ...CODE
};
export default HomePage;
Extract browser API interactions from the component
Sometimes, components need to interact with browser APIs and its resources (such as the global window, local storage, cookies, etc.). In cases like these, it’s a good idea to also extract that code from the component, at least to make it easier to unit testing the components. So let’s do that
export const changePageTitle = (title: string) => {
document.title = `Users by ${title}`;
};
Now let’s use that function in the component
import React, { useState } from 'react';
import { useLoadUsers } from '../hooks/useLoadUsers';
import { sortUsers } from '../homeUtils';
import { TSortBy } from '../../../types/TSortBy';
import { changePageTitle } from '../../../utils';
const HomePage = () => {
const [sortBy, setSortBy] = useState<TSortBy>('name');
const { users, error } = useLoadUsers();
const handleSortChange = (event: {
target: { value: React.SetStateAction<string> };
}) => {
setSortBy(event.target.value as TSortBy);
// NO DIRECT INTERACTION WITH BROWSER API HERE
// JUST CALL THE FUNCTION
changePageTitle(`Users by ${sortBy}`);
};
const sortedUsers = sortUsers(users, sortBy);
// ...CODE
};
export default HomePage;
Split the main component into smaller and more concise components
As we saw, the HomePage component implemented everything it needs to render. Wouldn’t it be nice if we split it into pieces and then just put these pieces all together like a puzzle? So, let’s create the following components: HomeHeader, UserList and ErrorMessage
import { TSortBy } from '../../../shared';
type HomeHeaderProps = {
handleSortChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
sortBy: TSortBy;
};
const HomeHeader = (props: HomeHeaderProps) => {
return (
<>
<h2>User List</h2>
<label htmlFor="sort">Sort by:</label>
<select id="sort" value={props.sortBy} onChange={props.handleSortChange}>
<option value="name">Name</option>
<option value="age">Age</option>
</select>
</>
);
};
export default HomeHeader;
import { IUser } from '../../../shared';
const UserList = (props: { users: IUser[] }) => {
if (!props.users?.length) {
return null;
}
return (
<ul>
{props.users?.map((user) => (
<li key={user.id}>
<img src={user.avatar} alt={`Avatar of ${user.name}`} />
<div>
<h3>{user.name}</h3>
<p>{user.age} years old</p>
</div>
</li>
))}
</ul>
);
};
export default UserList;
const ErrorMessage = (props: { errorMessage: string }) => {
if (!props.errorMessage) {
return null;
}
return (
<div>
<p>{props.errorMessage}</p>
</div>
);
};
export default ErrorMessage;
As we can see the components will take care of their own rendering internally. There is no need for conditional rendering in the main component (HomePage).
Now, let’s refactor the HomePage component to use these pieces and see the final result
// ..imports
const HomePage = () => {
const [sortBy, setSortBy] = useState<TSortBy>('name');
const { users, error } = useLoadUsers();
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSortBy(event.target.value as TSortBy);
changePageTitle(`Users by ${sortBy}`);
};
const sortedUsers = sortUsers(users, sortBy);
return (
<div>
<HomeHeader handleSortChange={handleSortChange} sortBy={sortBy} />
<UserList users={sortedUsers} />
<ErrorMessage errorMessage={error} />
</div>
);
};
export default HomePage;
As we can see, the HomePage component now has a higher-level, cleaner and less imperative code, without implementation details of things that are not relevant to it. It simply calls the functions and components it needs, allowing them to internally resolve their implementations, exposing to the main component only what is necessary. Also the children components are really simple and concise.
Some of the proposed changes may seem unnecessary, but believe me, in the long run, these practices can contribute a lot for the application’s scalability, maintenance, add new features and testing.
Last but not least, I would like to suggest an anatomy for the components. A way to organize things within the components to make sure all the components have the same “look”. This can improve the understanding and the development experience
const HomePage = () => {
// 1. HOOKS (FROM REACT, LIBS AND YOUR OWN CUSTOM HOOKS)
const [sortBy, setSortBy] = useState<TSortBy>('name');
const { users, error } = useLoadUsers();
// 2. HANDLE FUNCTIONS
// FOR EVENTS AND FUNCTIONS THAT WILL BE PASSED
// AS PROPS TO CHILDREN COMPONENTS
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSortBy(event.target.value as TSortBy);
changePageTitle(`Users by ${sortBy}`);
};
// 3. PROCESSING DATA
// STATE, PROPS, ANYTHING THAT NEED A POLISHING BEFORE RENDER
const sortedUsers = sortUsers(users, sortBy);
// 4. RENDER
return (
<div>
<HomeHeader handleSortChange={handleSortChange} sortBy={sortBy} />
<UserList users={sortedUsers} />
<ErrorMessage errorMessage={error} />
</div>
);
};
export default HomePage;
The above anatomy is just a suggestion, but I strongly recommend that you follow it or come up with your own way to organize things within the component. Just make sure your components always follow the same pattern
Well, that’s all. I hope this post can be useful when you are creating your components. If you want to see the finished code in details, check out the repo.
Thank you for reading this far.
Regards!