A neat little trick to avoid useEffect in React

Shivam Jha

Shivam Jha / July 17, 2023

5 min read

Problem

I came across a peculiar problem once. For the sake of this article, let's go with this contrived example:

I have a component MyDumbComponent.tsx and it receives an id with initial state value and uses that state to fetch some data. That state can also be manipulated inside same component:

import { useEffect, useState } from 'react'
import todos from '../data/todos.json'

type Unpacked<T> = T extends (infer U)[] ? U : T

export default function MyDumbComponent({ initialId }: { initialId: number }) {
  const [id, setId] = useState(initialId)
  const [todoData, setTodoData] = useState<Unpacked<typeof todos> | null>(null)

  useEffect(() => {
    const allTodos = todos
    const selectedTodo = allTodos.find(todo => todo.id === id) ?? null
    setTodoData(selectedTodo)
  }, [id])

  return (
    <>
      <div
        style={{
          height: '200px',
          width: '80vw',
          overflow: 'auto',
          margin: '20px',
        }}
      >
        <code>
          <pre>{JSON.stringify(todoData, null, 2)}</pre>
        </code>
      </div>
      <small>Child id: {id}</small>
      <br />
      <br />
      <button onClick={() => setId(prev => prev + 1)}>+</button>
      <button onClick={() => id > 1 && setId(prev => prev - 1)}>-</button>
    </>
  )
}

When I clicked on + and - button, it would change the id and will fetch a new todo detail and show it to user. See Code example demo

This worked perfectly fine and as expected. The issue came when I wanted to update the id(props) in parent. My dumb common sense would say it should also re-render. But to my suprise it didn't. Here I updated the state:

import { useState } from 'react'
import './App.css'
import MyDumbComponent from './components/MyDumbComponent'

function App() {
  const [count, setCount] = useState(1)

  return (
    <div>
      <p> Parent state variable: {count} </p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment parent state
      </button>
      <br /> <br />
      <br />
      <MyDumbComponent initialId={count} />
    </div>
  )
}

export default App

Here it can be seen React does not 'reload' child component if the prop is used as an initial value for state, even when the prop gets changed

At first, it can be unintuitive. The reason is that React updates a component when it's state changes or it's props changes. If React just throw away and child and re-make a new one everytime one of it's props gets changed, it would also have to create new DOM nodes, create new ones, and set those. These can be expensive, specially if the props change frequently or has a large number of such changing props. All of that is expensive and slow. So, React will re-use the component that was there since it's the same type and at the same position in the tree.

Using useEffect as a state updater

I am guilty of using a second effect in this scenarioπŸ˜… It would like: Hmm.. so we need to do something based on when the prop is changed.. what gets fired when the prop changed... useEffect with that prop in dependency!! So, I would add this effect after the 1st one(imo the first useEffect should be relaced with react-query or some other data fetching lib, too). But none-the-less, this is how that would go:

useEffect(() => {
  // Changing children's state whenever our prop `initialId` changes
  setId(initialId)
}, [initialId])

Here it can be seen this appraoch of using an useEffect (tongue-twister, right?) to update the vale of state initialized with some prop works

But this solution can be better. The useEffect updated the value of state in 2nd render. Also, it is a good rule of thumb to prevent using useEffect as long as one can. I have noticed this increases readability and prevent some bugs with not-very-cared use of useEffect. This advice has helped me remembering this: useEffect should only be used when an external service / something outside the React paradigm (like custom listeners) need to be integrated with React. So useEffect can be thought of as useSyncronise

Solution: using Keys to "reload" a React Component

So, what is the way? Keys to the rescue!!πŸ”‘ If a component has a key and it changes, React skips comparion and makes new fresh component So you can consider Keys as an "identity" or source of truth, if you will, for a component. Hence, if the Key changes, the component must be reloaded from scratch. This is the same reason you need keys while rendering a list, so that React can differentiate you list items when (if) their position / order changes within that list.

So, in our case, we can just pass the key to child component and it will be recreated from scratch:

import { useState } from 'react'
import './App.css'
import MyDumbComponent from './components/MyDumbComponent'

function App() {
  const [count, setCount] = useState(1)

  return (
    <div>
      <p> Parent state variable: {count} </p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment parent state
      </button>
      <br /> <br />
      <br />
      <MyDumbComponent key={count} initialId={count} />
    </div>
  )
}

export default App

Conveniently, I found that the new React docs has an article on resetting state with Keys

Further Read: