facebook / react

The library for web and native user interfaces.

Home Page:https://react.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Feature Request - useEffect - Provide boolean values on whether dependency has changed.

yamarco opened this issue · comments

Couldn't find this feature request already (though it sounds like you probably definitely got it) but it would be really great if dependencies could be forwarded in the useEffect function props and converted to a boolean declaring whether the dependency has changed (aka caused a re-render) or not.

For example:

useEffect((firstDepChanged, secondDepChanged) => {
  if(secondDepChanged) {
    console.log('Do Something with both deps but only when secondDepChanged');
    console.log(firstDep, secondDep);
  }
}, [firstDep, secondDep])

I can imagine that this could result in some performance decrease but we could consider making a dedicated version of useEffect like useObservableEffect or something like that (I'm not great at naming so i'm sure the community can come up with a better name :D)

Currently we use a workaround which requires using a ref to hold the previous values and comparing them inside the useEffect.

I'm sure there are plenty of other options like adding a third prop to useEffect indicating the indexes of the dependencies that would trigger the update. Something like:

useConditionalEffect(() => {
  console.log('Do Something with both deps but only when secondDep changed');
  console.log(firstDep, secondDep);
}, [firstDep, secondDep], [1])

This solution could also improve performance as we would preventatively know which dependencies to watch.

That would be a really cool feature indeed.

Personally as a workaround for now, I use the usePrevious hook or just split the useEffects to single dependencies.

If you want to do something only if one dependency changes, you can just use a separate useEffect:

useEffect(() => {
    console.log('Do Something only when secondDepChanged');
  }, [secondDep])

@vkurchatkin I failed to mention that i need access to the latest state of both of them. Obviously if i did not need firstDep in the useEffect i could just remove it. I will update my comment to reflect this. tx for noticing it.

@yamarco you can access latest state like this, just don't add it to dependencies

There are two problems with that.

  1. Linter will complain
  2. I will not have access to the latest value of that variable, in this example i want to make sure i always have access to the latest value of firstDep but i don't want to trigger a side effect if it changes. The side effect should only trigger when secondDep has changed.
  1. Well, yes. You can suppress it, if you know what you are doing
  2. That is not possible. The only way to always have access to the lates value is to trigger effect

I guess in someway you could say that this is either a possible duplicate or an extension to #22132 ?

@yamarco you already could access the latest value of both and trigger the useEffect even if both are not in the dependencies.

// Assuming secondDep is a state/prop
useEffect(() => {
  console.log('Latest value of firstDep is:', firstDep);
  console.log('The useEffect is triggered cuz  secondDep changed to the value:', secondDep);
}, [secondDep]);

In the above code, it will be triggered in 2 cases, once component mounts for first time and if secondDep is changed only, and once the function is triggered the latest value of firstDep will be used

What do you think about this?

const functionRef = useRef(() => null)

useEffect(() => {
  functionRef.current = () => {
    // do something...
  }
}, [varA, varB, varC])

useEffect(() => {
  functionRef.current()
}, [varA])

It's hard to think of solving this problem without useRef, but I think it looks a bit cleaner this way.
I'll keep watching 👁️ if something new comes up.

@FrancoMG it does exactly the same as

useEffect(() => {
   // do something
}, [varA])

Maybe i need to give a more concise example. Let's use a custom hook (or context) which always "re-renders" when anything inside that context or hook changes.

export const useCustomHook = ({ enabled = false }) => {
  const [data, setData] = useState(null);

  useEffect(() => {
    if(enabled) {
      axios.get('https://www.example.com').then(data => setData(data));
    }
  }, [enabled])

  return { enabled, data }
}

export const Component = ({ name }) => {

  const { data } = useCustomHook({ enabled: true });

  useEffect(() => {
    // this will trigger even if name didn't actually change
    // ideally i can check if name changed but always have access to the latest value of data
    console.log(name, data);
  }, [name, data]);

}

I'm not 100% sure this is representative of the case as obviously in this case there are many other ways to manage it. It's just cases like this one which require using a ref to compare values happen all the time.

@yamarco Just do

  useEffect(() => {
    console.log(name, data);
  }, [name]);

@yamarco In my case I prefer not to compare if a value has changed. You could end up doing spaghetti coding in my opinion.

In your example, if you want to run console.log (name, data) only when the name has changed, I would adapt it to something like this:

export const Component = ({ name }) => {
  const { data } = useCustomHook({ enabled: true })
  const functionRef = useRef(() => null)

  useEffect(() => {
    functionRef.current = () => {
      console.log(name, data)
    }
  }, [name, data])

  useEffect(() => {
    functionRef.current()
  }, [name])
  
  // ...
}

@FrancoMG yes this is quite a nice alternative solution with useRef, it nevertheless has a lot of "unnecessary" boiler plate. hence my feature request. If we were to get info about what changed then we can achieve the same with

export const Component = ({ name }) => {
  const { data } = useCustomHook({ enabled: true })

  useEffect((nameChanged, dataChanged) => {
    if(nameChanged) {
      console.log(name, data);
    }
  }, [name, data])
  
  // ...
}

@yamarco you can achieve exactly the same with this

 useEffect(() => {
    // only if name changed
  }, [name])

 useEffect(() => {
    // if name or data changed
  }, [name, data])

+1 on this ticket, this is indeed similar to what I've posted on #22132 (in particular probably the same solution as proposed by @Sangrene #22132 (comment))
I don't know what is the best solution for sure, but there is definitely something off in the way useEffect is working currently. It has been going unnoticed, but now that react-hooks/exhaustive-deps is on in many projects (thanks Next.js :)) it becomes visible that most of us are struggling to use it.
The concepts of "dependency" and "effect trigger" are not the same and thus should probably not share the same argument.

commented

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

Still relevant

commented

@eric-burel @yamarco it looks like their official response to this us to use refs for "event effects": #22879 (comment)

So this is the solution that is OK with linter:

const firstDepRef = useRef(firstDep);

useEffect(() => firstDepRef.current = firstDep, [firstDep]);

useEffect((=> {
  console.log(firstDepRef.current, secondDep);
}, [secondDep]);

I'm still puzzled though, why this is preferred to just omitting the dep. It seems that the React's team position on this is very agressive.

I choose to just suppress the linter, because this code does exactly what I want:

useEffect((=> {
  console.log(firstDep, secondDep);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [secondDep]);

I run the effect only when secondDep changes and I only care about firstDep value to be in sync with secondDep change. When firstDep changes, but secondDep does not – I choose to ignore this case.

Now, all examples of why this may lead to bugs describe a use case, when the operation inside effect is async, and may execute code later, when firstDep becomes out of sync with secondDep. Like here: #22743 (comment). Surely, in that case it's a serious issue, because async code is involved. However not every effect handler does async operations, and it's imo an overkill to always account for this case.

@eur00t The problem with suppressing the linter is that you can lose the state of parameters that you didn't send to the dependency array.

Solidjs uses an interesting solution, you can untrack the dependencies you don't use or be explicit with the ones you do use.

https://www.solidjs.com/docs/latest/api#on

commented

@FrancoMG I’m not losing the state of parameters as long as there are no async operations in the effect that rely on those parameters.

If there are such async operations (that’s the case that everyone is optimizing for) – then yes, it’s a bug, and there are many ways to fix it: refs, adding all deps and reseting the effect on change, etc.

This can be done in userspace. There's no need for this feature to be built into React itself.

This can be done in userspace. There's no need for this feature to be built into React itself.

Everything can be done in userspace in a certain sense, however this issue shows that the current API of useEffect is somehow slightly off, as it mixes concepts of "dependencies" of the effect and "triggers" of the effect in the same parameter.

I think thats arguing semantics tbh. By "dependencies" of the effect we mean what triggers the effect. You're thinking of dependencies in terms of what variables are being used by the effect. The lint rule is whats confusing because it assumes that any variable used by the effect is a "dependency" that should trigger it. But thats not always true as you have figured out. The rule is more of a suggestion / reminder for the common case to prevent simple mistakes. Also you'll have the latest values whenever the effect does trigger for whatever variables you're using so there's no real technical issue here.

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!