best practices

No useEffect — No bugs

The code provided in this article is simplified as much as possible to avoid overwhelming the reader with code and to convey the essence of the problem and its solution. In interactive examples, heading tags (`h1...h6`) are replaced with span tags to prevent potential conflicts with the main page structure.

In the React development environment, built-in hooks are essential tools for managing side effects and component states. As suggested by the title of this article, we will be focusing specifically on the useEffect hook. It's a powerful tool for handling side effects in functional components, such as making API requests, setting up event subscriptions, updating document titles, and manipulating the DOM outside of regular rendering. However, when working with inputs, excessive use of useEffect can lead to unforeseen bugs and complicate the application logic. This article will delve into a common mistake associated with using useEffect in input components, as well as propose a more efficient approach to help avoid problems and make the code cleaner and more predictable.

To understand what is being discussed, let's code. We will consider the problem using a primitive autocomplete component as an example. The first step is, of course, to render the input itself, store its state, and define the handleChange . So far, everything is familiar and clear:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
5 setValue(event.target.value);
6 };
7
8 return (
9 <Layer title="CharacterSelector">
10 <div className="flex flex-col gap-2">
11 <Label>Name</Label>
12 <Input
13 value={value}
14 onChange={handleChange}
15 />
16 </div>
17 </Layer>
18 );
19};
20

The next task is to load data based on the value in the input and display it in the List component. Most often, the following code is written for this task:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const [data, setData] = useState<ICharacters>([]);
5
6 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
7 setValue(event.target.value);
8 };
9
10 useEffect(() => {
11 if (value.length > 0) {
12 loadData(value).then((result) => {
13 setData(result);
14 });
15 } else {
16 setData([]);
17 }
18 }, [value]);
19
20 return (
21 <Layer title="CharacterSelector">
22 <div className="flex flex-col gap-2">
23 <Label>Name</Label>
24 <div className="relative h-14">
25 <Input
26 value={value}
27 onChange={handleChange}
28 />
29 <div className="absolute top-16 w-full">
30 <List items={data} />
31 </div>
32 </div>
33 </div>
34 </Layer>
35 );
36};
37

Here, another state variable — data has been added to store the loaded options for the List component. Additionally, useEffect has been added, which, upon the change of the input value, will load data by sending a request via loadData async function and updating the data state, provided that the value is not an empty string. It's worth noting that this code doesn't account for race conditions or debounce logic because these aren't relevant for understanding the current problem.

Now, let's see how the code works in the browser. Enter a value in the input, for example, a character name "Peter".

An interactive output of the code above

If you enter "Peter", two suggestions should appear under the input: "Peter Parker" and "Peter Quill". However, it isn't yet possible to click on a suggestion to insert its value into the input. So, let's add this logic:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const [data, setData] = useState<ICharacters>([]);
5
6 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
7 setValue(event.target.value);
8 };
9
10 useEffect(() => {
11 if (value.length > 0) {
12 loadData(value).then((result) => {
13 setData(result);
14 });
15 } else {
16 setData([]);
17 }
18 }, [value]);
19
20 const handleSelectCharacter = (character: ICharacter) => {
21 setValue(character.name);
22
23 setData([]);
24 };
25
26 return (
27 <Layer title="CharacterSelector">
28 <div className="flex flex-col gap-2">
29 <Label>Name</Label>
30 <div className="relative h-14">
31 <Input
32 value={value}
33 onChange={handleChange}
34 />
35 <div className="absolute top-16 w-full bg-neutral-100 dark:bg-neutral-900">
36 <List
37 items={data}
38 onClick={handleSelectCharacter}
39 />
40 </div>
41 </div>
42 </div>
43 </Layer>
44 );
45};
46

The function for handling the selection of a suggestion, handleSelectCharacter, has been added, which takes the selected suggestion as an argument — character. This function updates the input value by inserting the selected character's name (from the character argument) and resets the data state.

It seems that at this stage everything should work perfectly. But let's still look at the result in the browser. Enter the value "Peter" in the input again, and after the suggestions appear, try clicking on the first one.

An interactive output of the code above

If you selected the first suggested option, "Peter Parker", this value indeed appeared in the input. However, the List component did not disappear (although it should have, since the data state is reset in the handleSelectCharacter function), and rather even changed — the only remaining suggestion is "Peter Parker".

Why did this happen? After all, the data state is explicitly reset when the handleSelectCharacter function is called.

At this stage, it's worth returning to the code to understand what went wrong:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const [data, setData] = useState<ICharacters>([]);
5
6 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
7 setValue(event.target.value);
8 };
9
10 useEffect(() => {
11 if (value.length > 0) {
12 loadData(value).then((result) => {
13 setData(result);
14 });
15 } else {
16 setData([]);
17 }
18 }, [value]);
19
20 const handleSelectCharacter = (character: ICharacter) => {
21 setValue(character.name);
22
23 setData([]);
24 };
25
26 return (
27 <Layer title="CharacterSelector">
28 <div className="flex flex-col gap-2">
29 <Label>Name</Label>
30 <div className="relative h-14">
31 <Input
32 value={value}
33 onChange={handleChange}
34 />
35 <div className="absolute top-16 w-full bg-neutral-100 dark:bg-neutral-900">
36 <List
37 items={data}
38 onClick={handleSelectCharacter}
39 />
40 </div>
41 </div>
42 </div>
43 </Layer>
44 );
45};
46

So, the suggestion list is indeed cleared when handleSelectCharacter is called, but besides this, the value in the input changes. And since the input value is in the dependencies array of the useEffect, the body of useEffect is triggered again when it changes. Since the input is not empty, the code inside the if condition block executes, making another request to load updated suggestions based on the new value ("Peter Parker"). The result is an input filled with the value "Peter Parker" and a suggestion list containing only one option: "Peter Parker", which matches the input value.

Agree, an unpleasant bug? To fix this, we need to consider how to do it correctly without breaking the existing logic.

A common solution to this problem is to introduce a boolean flag called disableSuggestions, which will indicate when not to display suggestions.

Let's take a look at the code of this solution:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const [data, setData] = useState<ICharacters>([]);
5
6 const [disableSuggestions, setDisableSuggestion] = useState(false);
7
8 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
9 setValue(event.target.value);
10
11 setDisableSuggestion(false);
12 };
13
14 useEffect(() => {
15 if (disableSuggestions) {
16 return;
17 }
18
19 if (value.length > 0 && !disableSuggestions) {
20 loadData(value).then((result) => {
21 setData(result);
22 });
23 } else {
24 setData([]);
25 }
26 }, [value, disableSuggestions]);
27
28 const handleSelectCharacter = (character: ICharacter) => {
29 setValue(character.name);
30
31 setDisableSuggestion(true);
32
33 setData([]);
34 };
35
36 return (
37 <Layer title="CharacterSelector">
38 <div className="flex flex-col gap-2">
39 <Label>Name</Label>
40 <div className="relative h-14">
41 <Input
42 value={value}
43 onChange={handleChange}
44 />
45 <div className="absolute top-16 w-full bg-neutral-100 dark:bg-neutral-900">
46 <List
47 items={data}
48 onClick={handleSelectCharacter}
49 />
50 </div>
51 </div>
52 </div>
53 </Layer>
54 );
55};
56

The disableSuggestions state variable will be set to true when a suggestion is selected (in the handleSelectCharacter function) and false when typing in the input (in the handleChange function). Check the result by applying the same steps.

An interactive output of the code above

Although everything works, there is a sense that this is an unpleasant workaround, and the reason for this workaround is useEffect. After all, it reacts to any input value change, although it should only respond to changes caused by user typing, specifically the onchange event.

After identifying the problem, it's time to share a more effective solution. It comes down to the concept of "Clear Instructions". Instead of modifying one field, causing reactions that execute additional code and potentially trigger changes in other components, I suggest using clear, definitive instructions within a single function. This approach avoids unclear reaction chains and ensures predictable behavior.

Enough theory, let's move on to practice. Here is the initial code with one input again:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
5 setValue(event.target.value);
6 };
7
8 return (
9 <Layer title="CharacterSelector">
10 <div className="flex flex-col gap-2">
11 <Label>Name</Label>
12 <Input
13 value={value}
14 onChange={handleChange}
15 />
16 </div>
17 </Layer>
18 );
19};
20

Next, the List component is added, but the handleSelectCharacter function has not been included yet:

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const [data, setData] = useState<ICharacters>([]);
5
6 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
7 const newValue = event.target.value;
8
9 setValue(newValue);
10
11 if (newValue.length > 0) {
12 loadData(newValue).then((result) => {
13 setData(result);
14 });
15 } else {
16 setData([]);
17 }
18 };
19
20 return (
21 <Layer title="CharacterSelector">
22 <div className="flex flex-col gap-2">
23 <Label>Name</Label>
24 <div className="relative h-14">
25 <Input
26 value={value}
27 onChange={handleChange}
28 />
29 <div className="absolute top-16 w-full bg-neutral-100 dark:bg-neutral-900">
30 <List items={data} />
31 </div>
32 </div>
33 </div>
34 </Layer>
35 );
36};
37

The difference from the previous solution at the same stage is that there is no useEffect. The logic that was inside useEffect (sending a request using the loadData function and subsequently updating the data state) is now right inside the handleChange function. The result of this solution is a small function that describes literally everything that should happen in the application when typing text in the input. This is what is called "Clear Instructions".

Now, we just need to add the character selection handler — the handleSelectCharacter function. In the current solution, this is very simple.

Code implementation
1const CharacterSelector: FC = () => {
2 const [value, setValue] = useState('');
3
4 const [data, setData] = useState<ICharacters>([]);
5
6 const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
7 const newValue = event.target.value;
8
9 setValue(newValue);
10
11 if (newValue.length > 0) {
12 loadData(newValue).then((result) => {
13 setData(result);
14 });
15 } else {
16 setData([]);
17 }
18 };
19
20 const handleSelectCharacter = (character: ICharacter) => {
21 setValue(character.name);
22
23 setData([]);
24 };
25
26 return (
27 <Layer title="CharacterSelector">
28 <div className="flex flex-col gap-2">
29 <Label>Name</Label>
30 <div className="relative h-14">
31 <Input
32 value={value}
33 onChange={handleChange}
34 />
35 <div className="absolute top-16 w-full bg-neutral-100 dark:bg-neutral-900">
36 <List
37 items={data}
38 onClick={handleSelectCharacter}
39 />
40 </div>
41 </div>
42 </div>
43 </Layer>
44 );
45};
46

The logic remains the same — the new value (character) passed as an argument to the handleSelectCharacter function is assigned to the value state, and the data state is cleared. And this solution works.

To confirm, enter the character name "Peter" and then click on the first suggestion.

An interactive output of the code above

As with the result of the "useEffect Approach", the user can easily select the character "Peter Parker", and there will be no side effects.

To summarize — in the "Clear Instructions Approach", the handleSelectCharacter function specifies resetting the data state, meaning the suggestion list should not be displayed when this function is called — and it isn't. Additionally, there are no side effects, which in turn can reduce the number of bugs in the application. Moreover, compared to the "useEffect Approach", one fewer state variable is used, and everything still works. This is why I prefer the "Clear Instructions Approach".

Comparison

AspectuseEffect ApproachClear Instructions Approach
State Change ReactionReacts to any change in input value, leading to unnecessary re-renders.Encapsulates all relevant logic within specific event handlers, ensuring controlled state updates.
Handling Side EffectsMay cause race conditions if multiple requests are made in quick succession.Prevents unnecessary re-renders by only triggering side effects when needed.
Component ComplexityAdds complexity by spreading logic across multiple parts of the component.Simplifies the component by consolidating logic into clear and concise functions.

Advantages of the Clear Instructions Approach

  • Reduced Complexity: Less reliance on useEffect, making the component easier to understand and maintain.
  • Improved Performance: Fewer re-renders and better handling of side effects.
  • Better Control: Clear separation of concerns with logic contained within event handlers.

This topic may seem overly simplistic, but I often see such workaround solutions ("useEffect Approach") everywhere — I can't recall anyone not using useEffect when working with inputs. Most likely, the root of the problem is poor understanding of the tool's documentation, which clearly states the purpose of useEffect hook:

useEffect is a React Hook that lets you synchronize a component with an external system.

If this seems simple and obvious to you, ask your colleague how they write such code, and you might be very surprised.

Last updated on by @skrykateSuggest an improvement on Github repository.