An Ode to React Effects

March 31, 2023 11 minute read

Hooks got mixed responses when they were announced. I remember the miles-long GitHub thread explaining the new API and the avalanche of questions trying to make sense of dependency arrays and that hooks couldn’t be run conditionally.

It was a paradigm shift.

As a JavaScript developer, I was used to letting go of established practices in favor of something new and unproven. We started using hooks in production for our new UIs, leaving the existing ones to use the old class-based syntax.

And they quickly proved to be a better model for reasoning about data flow in a component. With classes, you had to jump from method to method and split your logic across them. While this approach is aligned with good engineering practices, having a function that runs top-to-bottom every time was far simpler.

Yes, you had to follow the rules of hooks™. But in reality, very few components were complex enough to leave you scratching your head, thinking about how to implement them. Adding a piece of state without the boilerplate of classes was a godsend.

But some hooks proved to cause design problems for many developers, specifically the useEffect hook.

The Trouble With Effects

People have criticized the design of useEffect in various forms, and it was even called a mistake in a tweet by a React team member. But all problems with the hook boil down to its unclear purpose.

Its API is deceiving.

Many engineers’ first encounter with the hook ends up with an infinite render loop because of a missing dependency array. Then some continue to face problems because they forget to unsubscribe an event handler.

It follows React’s philosophy of staying close to the language, but you can only use it correctly with contextual knowledge. You could argue that this is true for every function, and you can’t expect better if you don’t read the docs.

But a dependency array is not a frequently used pattern outside of React, while the callback function is. So adding it at the end doesn’t come intuitively, and the default behavior without one is confusing.

The core team could’ve made it more obvious by passing an object instead of multiple values and trading verbosity for additional clarity.

I agree that the API can be a foot gun until you get used to it. But that’s the only criticism that makes sense.

Effect Problems are Software Design Problems

Maybe useEffect is a too low level of a primitive. Perhaps the React team should’ve exposed lifecycle hooks directly like useMount or useUnmount because many of the problems people face with useEffect beyond its API are related to bad software design.

Developers want primitives that abstract the most details, but this hook wasn’t meant to be that. It’s the lowest common denominator between all possible cases where a component wants to do something outside of rendering JSX.

We’ll keep going back to this idea throughout the article.

The Data Fetching Problem

The most common side effect a component can have is fetching data from an external API. And the criticism toward useEffect is that it encourages data fetching on component mount, which leads to spinner waterfalls and an overall bad user experience.

function Component() {
  const [data, setData] = useState([])

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://some.rest.api/v1')
        const body = await response.json()
        setData(body)
      } catch (error) {
        console.error('Error fetching data:', error)
      }
    }

    fetchData()
  }, [])

  if (data.length === 0) {
    return <Spinner />
  }

  return (
    <>
      <CompponentThatFetchesDataToo />
    </>
  )
}

The child component will only start fetching once the parent component mounts, retrieves the data, and re-renders. But this is not a problem with the hook.

If UX and speed are critical to your app, then fetching data as a part of a component’s rendering is not what you should be doing. Many enterprise dashboard applications sitting behind SSO don’t have that need, and useEffect is a perfect solution.

But if you want to provide a better UX, you should employ SSR and fetch data on the server or start fetching data when the route changes. This is not a flaw in the hook, it’s a wrongly diagnosed problem.

Chaining Effects are Complex

The complexity of having many useEffect calls in a component is another point of criticism. That problem is further exacerbated if these effects get chained - one triggers another, triggers another and so on.

function UserList {
  const [users, setUsers] = useState([])
  const [processedUsers, setProcessedUsers] = useState([])

  // Fetch data
  useEffect(() => {
    const fetchUsers = async () => {
      try {
        const response = await fetch('https://some.rest.api/v1')
        const data = await response.json()
        setUsers(data)
      } catch (error) {
        console.error('Error fetching users:', error)
      }
    }

    fetchUsers()
  }, [])

  // Process data
  useEffect(() => {
    const processUsers = () => {
      const mappedUsers = users.map((user) => ({
        ...user,
        fullName: `${user.name.first} ${user.name.last}`,
      }))

      setProcessedUsers(mappedUsers)
    }

    if (users.length > 0) {
      processUsers()
    }
  }, [users])

  return (
    <div>
      <h1>Users List</h1>
      <ul>
        {processedUsers.map((user) => (
          <li key={user.id}>{user.fullName}</li>
        ))}
      </ul>
    </div>
  )
}

In this example, we have one effect responsible for retrieving the users on mount and another that maps them as a result of the initial array changing. This is the kind of design that will leave you bouncing up and down throughout the file, trying to figure out where something is happening.

It’s also a reason why people dislike useEffect

But I’d argue that if effects are focused and responsible for a single thing, their complexity is far easier to manage. And if you happen to be chaining effects that work on the same piece of data, you’re splitting up responsibilities too granularly.

function UsersList() {
  const [processedUsers, setProcessedUsers] = useState([])

  useEffect(() => {
    const fetchAndProcessUsers = async () => {
      try {
        const response = await fetch('https://some.rest.api/v1')
        const users = await response.json()

        const mappedUsers = users.map((user) => ({
          ...user,
          fullName: `${user.name.first} ${user.name.last}`,
        }))

        setProcessedUsers(mappedUsers)
      } catch (error) {
        console.error('Error fetching and processing users:', error)
      }
    }

    fetchAndProcessUsers()
  }, [])

  return (
    <div>
      <h1>Users List</h1>
      <ul>
        {processedUsers.map((user) => (
          <li key={user.id}>{user.fullName}</li>
        ))}
      </ul>
    </div>
  )
}

Splitting logic that should reside in one effect into multiple ones is what makes data flow harder to track, test, and reason about.

But again, this is not a new problem, and it’s not one specific to useEffect. It was the same when we used class components and tracked data across multiple lifecycle methods.

I’ve had a much better time working with the hook than I did with the class-based API.

The Problem with Large Effects

Even if they’re focused, having multiple large effects can be confusing and hard to test. Absolutely. But that’s not a problem specific to React. A component is nothing more than a function, and putting too much logic in a single function is bound to be problematic.

function UserList() {
  const [processedUsers, setProcessedUsers] = useState([])
  const [notification, setNotification] = useState(null)

  useEffect(() => {
    // Fetch users...
  }, [])

  useEffect(() => {
    const userThreshold = 50

    if (processedUsers.length > userThreshold && !notification) {
      setNotification(
        new Notification('User count alert', {
          body: `The number of users (${processedUsers.length}) has exceeded the threshold (${userThreshold}).`,
        })
      )
    }
  }, [processedUsers])

  return (
    <div>
      <h1>Users List</h1>
      {notification ? (
        <NotificationBanner notification={notification} />
      ) : null}
      <ul>
        {processedUsers.map((user) => (
          <li key={user.id}>{user.fullName}</li>
        ))}
      </ul>
    </div>
  )
}

Historically, we’ve solved this by splitting the logic into other smaller functions and abstracting the complexity.

One approach to resolve the problem of having many effects is to put some of them in child components. This doesn’t remove the complexity but spreads it in multiple places, making it easier to understand.

function UserList() {
  const [processedUsers, setProcessedUsers] = useState([])
  const [notification, setNotification] = useState(null)

  useEffect(() => {
    // Fetch users...
  }, [])

  return (
    <div>
      <h1>Users List</h1>
      <UserThresholdNotification userCount={processedUsers.length} />
      <ul>
        {processedUsers.map((user) => (
          <li key={user.id}>{user.fullName}</li>
        ))}
      </ul>
    </div>
  )
}

function UserThresholdNotification({ userCount }) {
  const [notification, setNotification] = useState(null)

  useEffect(() => {
    const userThreshold = 50

    if (processedUsers.length > userThreshold && !notification) {
      setNotification(
        new Notification('User count alert', {
          body: `The number of users (${processedUsers.length}) has exceeded the threshold (${userThreshold}).`,
        })
      )
    }
  }, [processedUsers])

  if (!notification) {
    return null
  }

  return (
    <div className="notification-banner">
      <h3>{notification.title}</h3>
      <p>{notification.body}</p>
    </div>
  )
}

I believe that most of the design problems developers face when working with React are solvable by extracting more custom components. This doesn’t change the application’s functionality, it just spreads it across multiple components.

The Problem with Complexity Hotbeds

However, you may still reach a case when you want one component to hold all that complexity, and there’s no going around that. In these cases, you can employ the same principle but extract the useEffect calls in a custom hook instead.

function UserList() {
  const [processedUsers, setProcessedUsers] = useState([])

  useEffect(() => {
    // Fetch users...
  }, [])

  // Send an analytics event on component mount
  useEffect(() => {
    const sendAnalyticsEvent = async () => {
      // Replace with your own analytics server URL
      const analyticsURL = 'https://your-analytics-server.com/events'

      try {
        await fetch(analyticsURL, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            eventType: 'component_mount',
            componentName: 'UsersList',
            timestamp: new Date().toISOString(),
          }),
        })
      } catch (error) {
        console.error('Error sending analytics event:', error)
      }
    }

    sendAnalyticsEvent()
  }, [])

  return (
    <div>
      <h1>Users List</h1>
      <ul>
        {processedUsers.map((user) => (
          <li key={user.id}>{user.fullName}</li>
        ))}
      </ul>
    </div>
  )
}

This way, the complexity stays at the same place, but we hide some of the details related to it. Custom hooks are my go-to abstraction tool because they let me keep the component body lean and only expose the final values a component needs to render the proper JSX.

function UserList() {
  const [processedUsers, setProcessedUsers] = useState([])

  useEffect(() => {
    // Fetch users...
  }, [])

  useComponentMountAnalytics('UsersList')

  return (
    <div>
      <h1>Users List</h1>
      <ul>
        {processedUsers.map((user) => (
          <li key={user.id}>{user.fullName}</li>
        ))}
      </ul>
    </div>
  )
}

By doing this you will most likely remove other helper functions, and useState calls to the custom hook, hiding further details from the component.

False Effect Problems

Sometimes you could also remove an effect entirely if you move the decision-making higher in the component tree. Recently, I was working on a component that had to accept a value as a prop and then manage it in local state.

function Component({ initialValue }) {
  const [value, setValue] = useState(null)

  useEffect(() => {
    if (initialValue && !value) {
      setValue(initialValue)
    }
  }, [initialValue])

  return (
    // ...
  )
}

Due to how the business logic works, this value was optional, and the component was written around this fact.

But if we rethink the component outside the scope of our domain, we could eliminate the additional complexity of the effect. The component could insist on getting a non-optional value and leave the parent component to make the check instead.

function Component({ initialValue }) {
  const [value, setValue] = useState(initialValue)

  return (
    // ...
  )
}

This way, we trade an unneeded effect for a simpler conditional check higher in the component tree.

Using Effects for Imperative Actions

Because of the wording, we often use the useEffect hook for all side-effects that a component could produce, including ones unrelated to rendering. But despite the name, that’s not what the hook is good for.

I’ll concede that the naming can deceive a lot of developers, after all it’s one of the hardest things in computer science.

I’ve seen (and implemented myself) event handlers that set a state flag tracked by an useEffect dependency array. Then that effect checks the position of the flag, and fires off a request.

This is a big culprit to unnecessarily increased component complexity.

const Component = () => {
  const [data, setData] = useState([])
  const [fetching, setFetching] = useState(false)

  useEffect(() => {
    if (fetching) {
      service.getNextPage().then((nextPageDaata) => {
        setData([...data, ...nextPageDaata])
        setFetching(false)
      })
    }
  }, [fetching])

  return (
    <div>
      <h1>Click Counter</h1>
      <Dashboard data={data} />
      <button onClick={() => setFetching(true)}>Load More</button>
    </div>
  )
}

Effects should run as a result of a component re-rendering. But by using them to handle events, we try to turn an imperative action into a declarative one, and that translation can be very confusing.

Avoid doing this approach.

Instead, treat the event as a regular imperative action and execute whatever functionality you need as a direct result of it. Make a call and set the new state when the promise resolves. Not everything has to be handled as a result of rendering.

As a rule of thumb if you have an effect which runs as a result of an event triggering, you can probably avoid it.

const Component = () => {
  const [data, setData] = useState([])

  const handleLoadMore = () => {
    service.getNextPage().then((nextPageDaata) => {
      setData([...data, ...nextPageDaata])
    })
  }

  return (
    <div>
      <h1>Click Counter</h1>
      <Dashboard data={data} />
      <button onClick={handleLoadMore}>Load More</button>
    </div>
  )
}

Using Effects for Computed Data

Often you will want to display derivative data based on data that you’ve fetched. For example, the number of items in an array, the average of some values that all items have or an aggregate of a value in a collection.

In these cases, it’s tempting to reach for an effect and implement the logic to compute the derived data, using the raw input in the dependency array.

const UsersList = ({ users }) => {
  const [userCount, setUserCount] = useState(0)

  useEffect(() => {
    setUserCount(users.length)
  }, [users])

  return (
    <div>
      <h1>Users List</h1>
      <h2>Total Users: {userCount}</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

In this particular example, we’re adding an extra piece of state, and an effect for something that could be inlined inside the returned JSX instead. We’re adding complexity for no real gain.

An alternative, if we need to do something more computationally heavy, would be to use useMemo instead.

The Botom Line

The count of effects in a component is not the metric you want to optimize for. People use that as a proxy for complexity because they want a quantifiable value, but that is often deceiving because complexity is subjective.

We should focus on our effects’ responsibilities, and ensure that they’re not doing more than one thing. At the same time, we should avoid using effects for things outside their main purpose - doing something as a result of the component rendering.

Most of the problems with useEffect are rooted in bad software design, not the hook’s API.

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.