Callbacks and useEffect

Callbacks and useEffect
Photo by Julian Hochgesang / Unsplash

In the previous article, we talked about using objects in useEffect's deps. Now it's the time to talk about callbacks.

In a PR I've revied once I've seen the author implement a component that was responsible for selecting a consultant. It's a good idea to split your code and create smaller components. The issue was with a callback. Here is a dummy sample of the problem:

type Consultant = {
  name: string
}

const ConsultantSelector: React.FC<{
  didSelectConsultant: (consultant: Consultant) => void
}> = ({ didSelectConsultant }) => {
  const [selectedConsultant, setSelectedConsultant] = useState<Consultant | undefined>(undefined)
  
  useEffect(() => {
    if(selectedConsultant != null) {
      didSelectConsultant(selectedConsultant)
    }
  }, [selectedConsultant])

  return <View style={styles.consultantsContainer}>
    <Button title='Consultant Adam' onPress={() => { 
      setSelectedConsultant({ name: 'Adam'})
    }}/>
    <Button title='Consultant Daniel' onPress={() => { 
      setSelectedConsultant({ name: 'Daniel'})
    }}/>
  </View>
}



export default function App() {
  const [counter, setCounter] = useState(0)
  const [selectedConsultant, setSelectedConsultant] = useState<string | undefined>(undefined)

  return (
    <View style={styles.container}>
      <Text style={styles.paragraph}>
      {`Selected Consultant: ${selectedConsultant ?? 'unknown'}\n\nCounter: ${counter}`}
      </Text>
      <ConsultantSelector didSelectConsultant={(consultant) => {
          setSelectedConsultant(consultant.name);
          setCounter(c => c + 1);
      }}/>
    </View>
  );
}

At first, the code looks good. However, the problem lies in didSelectConsultant being passed into useEffect the deps list. As we can see didSelectConsultant in the App component is not memo'ed and at every render, a new instance of the same function is created. Thus every time didSelectConsultant has a different reference. As a result, when a consultant is selected we have infinite useEffect's runs:

0:00
/

useCallback as a fix?

The first idea could be to use useCallback in the App component:

export default function App() {
  const [counter, setCounter] = useState(0)
  const [selectedConsultant, setSelectedConsultant] = useState<string | undefined>(undefined)

  const didSelectConsultant = useCallback((consultant: Consultant) => {
    setSelectedConsultant(consultant.name)
    setCounter(c => c + 1);
  }, [setSelectedConsultant, setCounter])

  return (
    <View style={styles.container}>
      <Text style={styles.paragraph}>
      {`Selected Consultant: ${selectedConsultant ?? 'unknown'}\n\nCounter: ${counter}`}
      </Text>
      <ConsultantSelector didSelectConsultant={didSelectConsultant} />
    </View>
  );
}

Yes, that would fix the issue. However, the problem with useCallback this is parent component needs to know it must pass callbacks with constant reference. IMO it's very error-prone. With a refactor or two someone could remove useCallback in the App and the issue would come back.

I think it's better to solve a potential issue close to where it could appear. In this case, we could use useRef instead of useCallback :

const ConsultantSelector: React.FC<{
  didSelectConsultant: (consultant: Consultant) => void
}> = ({ didSelectConsultant }) => {
  const didSelectConsultantRef = useRef(didSelectConsultant);
  const [selectedConsultant, setSelectedConsultant] = useState<Consultant | undefined>(undefined)

  useEffect(() => {
    didSelectConsultantRef.current = didSelectConsultant
  });

  useEffect(() => {
    if(selectedConsultant != null) {
      didSelectConsultantRef.current(selectedConsultant)
    }
  }, [selectedConsultant])

  return <View style={styles.consultantsContainer}>
    <Button title='Consultant Adam' onPress={() => { 
      setSelectedConsultant({ name: 'Adam'})
    }}/>
    <Button title='Consultant Daniel' onPress={() => { 
      setSelectedConsultant({ name: 'Daniel'})
    }}/>
  </View>
}

useRef + useEffect remembers the last callback passed into the component. IMO for functions, this is a good way of solving the issue. It's also better than useCallback as a parent component doesn't need to know anything about its children.

You can experiment with the sample code here.