Leave useEffect Alone!

Avoiding unnecessary re-renders in React.

Aviv Segal
Melio’s R&D blog
Published in
8 min readJul 23, 2024

--

As a full-stack developer at Melio, working on React-based web applications, I’ve seen firsthand that frontend development is as complex as backend work. Managing the state, ensuring responsiveness, and optimizing performance all contribute to the intricate challenges of the frontend.

Every frontend framework has its inner workings, challenges, and nuances, and React is no different. Some of its mechanics require a level of understanding to be used properly, like the famous useEffect hook.

useEffect is one of the most popular hooks provided by React; it allows us to synchronize the component with external factors/services, including fetching data, subscriptions, manual changes, etc, but it’s also very easily abused. In this blog post, I’ll discuss a few scenarios every developer should avoid and how the React team puts everything on the table in the new React docs (react.dev).

Leave useEffect alone!

Oops! … I did it again

The following example simulates a race condition caused by a wrong use of the useEffect hook. The expected behavior is that the last request should update the response.

// 🔴 Can we guarantee the last request will set the response?
function RaceConditionExample() {
const [counter, setCounter] = useState(0);
const [response, setResponse] = useState(0);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
const request = async (requestId) => {
setIsLoading(true);
await sleep(Math.random() * 3000);
setResponse(requestId);
setIsLoading(false);
};
request(counter);
}, [counter]);

const handleClick = () => {
setCounter((prev) => ++prev);
};

return <>//....
<button onClick={handleClick}>Increment</button> //...
</>;
}

Unexpected behavior: an earlier request overrides and sets the response (You’re welcome to try to fix it on this codepen before moving on!).

Following the new React docs example, you can handle this race condition by implementing the clean-up function.


// ✅ Handling the racing condition by clean-up function
useEffect(() => {
let ignore = false;
const request = async (requestId) => {
setIsLoading(true);
await sleep(Math.random() * 3000);
if (!ignore) {
setResponse(requestId);
setIsLoading(false);
}
};
request(counter);

return () => {
ignore = true;
};
}, [counter]);
The expected behavior

Render me baby one more time

In the following examples, I’ll discuss how passing data in the wrong direction can cause extra renders.

Data in React should pass like a waterfall

Abuse 1: How many renders do we have here after the user clicks?

function Parent() {
const [someState, setSomeState] = useState();
return <Child onChange={(...) => setSomeState(...)} />
}

function Child({ onChange }) {
const [isOn, setIsOn] = useState(false);

useEffect(() => {
// 🚨 Will trigger an extra render
onChange(isOn);
}, [isOn, onChange]);

function handleClick() {
️⃣ // Will trigger the first render after clicking
setIsOn(!isOn);
}

return <button onClick={handleClick}>Toggle</button>;
}

The click event updates the local state (that will be the first render); the effect is now running and invoking the callback provided by the parent component; it also updates some states (that will be the second render).

Solution:

function Parent() {
const [someState, setSomeState] = useState();
return <Child onChange={(...) => setSomeState(...)} />
}

function Child({ onChange }) {
const [isOn, setIsOn] = useState(false);

// ✅ Good: Perform all updates during the event that caused them
function handleClick() {
const newValue = !isOn;
setIsOn(newValue);
onChange(newValue);
}

return <button onClick={handleClick}>Toggle</button>;
}

There is no real need for onChange to trigger in a useEffect. It can simply be triggered as part of the on-click handler, and we’ll get the same result but with one less render.

Abuse 2: Breaking the data flow chain

function Parent() {
const [data, setData] = useState(null);

return <Child onFetched={setData} />;
}

// 🔴 Avoid: Passing data to the parent in an Effect
function Child({ onFetched }) {
const data = useFetchData();

useEffect(() => {
if (data) {
// 🇮🇹 Making spaghetti?
onFetched(data);
}
}, [onFetched, data]);

return <>{JSON.stringify(data)}</>;
}

Keeping the data flow from parent to children will keep the app easy to trace, maintain, and debug your application.

Plus, it suggested maintaining readable and easy-to-follow code. In practice, there is much more code, and when things get complicated, it’s tough to track what component passes callbacks to the parent and how far it goes in the wrong direction. It’s also hard to understand the ‘source of truth’ for the data.

Solution: Manage the state on the parent component!

// Suggetion #1 - pass fetch logic to higer component
function Parent() {
const data = useFetchData();

// ✅ Good: Passing data down to the child
return <Child data={data} />;
}

function Child({ data }) {
return <>{JSON.stringify(data)}</>;
}

If, for any reason, we have to keep the fetching logic in the child component, it’s preferable to have the parent interact with the data using handlers that are passed down to the child instead of defining extra useState or useEffect:

// Suggetion #2 - pass onSuccess/onError handlers to the children component
function Parent() {
function handleSuccess = (data) => {
// some logic
}
function handleError = (error) => {
// some logic
toast(error.messasge)
}
// ✅ Good: Passing data down to the child
return <Child onSuccess={handleSuccess} onError={handleError} />;
}

function Child({ onSuccess, onError }) {
// ✅ Good:The hook invoke the handler on done, no other effects involved
const mutate = useMutateData({ onSuccess, onError });

return ...;
}
Leave him alone!

Born to make you initialized

Often, we have a one-time initialization that we expect to run once during the app’s runtime.

function App() {
useEffect(() => {
someOneTimeLogic();
}, []);
// ...
}

Assume the ‘someOneTimeLogic’ function is initializing something (like an auth provider) and shouldn’t run more than once.

I can think of a few reasons why you shouldn’t use useEffect in such cases:

  1. React always remounts the components in development (with strict mode), so you will have to have a cleanup function. Doesn’t it apply once, anyway? Why implement the cleanup if so? And to that, I’ll say…..)
  2. Concurrent Mode is not yet ready to be used, but its semantics around this are precise, and we should be prepared — there is no guarantee that your components will be rendered just once!

Alternative 1: Top-level flag to indicate if it's ever mounted before

let didInit = false;

function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
someOneTimeLogic();
}
}, []);
// ...
}

Alternative 2: Apply the logic before the app renders

if (typeof window !== 'undefined') {
// ✅ Only runs once per app load
someOneTimeLogic();
}

function App() {
// ...
}

Mount to make you happy

The React team has removed the “Can’t perform a React state update on an unmounted component” warning, so there is no longer any reason to avoid it. Trying to implement a workaround for it is worse than the original problem!

The warning is deprecated.

There is no memory leak, and trying to setState from an unmounted component will also not lead to an error; I can’t find any reason to prevent setState in such a situation. In practice, if you try to bypass it, it will be by adding an unnecessary useEffect.

A common workaround will be

const useMountedRef = () => {
const mounted = useRef(true);
useEffect(() => {
return () => {
mounted.current = false;
};
}, []);
return mounted;
};

const MyCompoenet = () => {
const [loading, setLoading] = useState(false);
const mountedRef = useMountedRef();

const handleDeleteBill = async (id) => {
setLoading(true)
await axios.delete(`/bill/${id}`);
// 🔴 Wrong - trying to avoid update state from unmounted component
if(mountedRef.current) {
setLoading(false)
}
}

return <button onClick={handleDeleteBill} disabled={loading}>Delete Bill</button>

}

You shouldn’t try to avoid updating the state from unmounted components because:

  1. It adds unnecessary useEffect
  2. It adds complexity to the code
  3. React is about to release the preserve state feature. This feature will keep the state when switching tabs without resetting it. Not updating the state today will probably force us to maintain the code in the future.
    For Example, you have a switching tabs component and want to support the new preserve mechanized (adding state-key for each tab). The user switches tabs during an API call. You are handling the unmounted scenario and not doing setState to the response. If they switch back, the data will be kept undefined (due to the upcoming preserve state mechanized). A refactor will be needed (and there is no actual reason to handle setState for unmounted components at all 🤷🏻)

const MyCompoenet = () => {
const [loading, setLoading] = useState(false);

const handleDeleteBill = async (id) => {
setLoading(true)
await axios.delete(`/bill/${id}`)
// ✅ No reason to be afraid to update the state
setLoading(false)
}

return <button onClick={handleDeleteBill} disabled={loading}>Delete Bill</button>

}

Conclusion

  • If you are trying to pass data up to the parent component by callback, consider lifting the whole state and passing it back to the children. This practice will optimize the renders and keep the data flow declarative and clean.
  • Fetching/calculating data inside the useEffect is okay, but you should always handle the cleanup to avoid race conditions.
  • Only set the local state inside a useEffect if you have a good reason, as doing so can result in redundant extra renders.
  • Don’t overuse useEffect; if you can calculate it during a render — do it.
  • There is no reason to be afraid to update the state from the unmounted component; trying to work around it will lead to abusing the react ecosystem.

When do I need an effect?

  • You need to initialize external connections/subscriptions/resources like WebSocket and SDK tools like Firebase, analytics tools, or libraries that expose APIs like Google Maps or ChartJS.
  • You have unhandled open connections/subscriptions to an external resource, and if the component is unmounted, it needs to be closed/handled.
  • You have an effect that should run only once during the component’s lifetime (useMount) but not once during the application’s lifetime.
  • When we have an optimization issue in practice and no other solution is found. For example, a large table — the user interacts with sort/filter options — that has problems, pagination is not an option, and virtualization is not an option. It’s a case needing memorization using useEffect/useMemo. (Using react 19? Read about React Compiler)

Thanks for reading, and don’t forget to leave useEffect alone.

visit our career website

--

--