React’s useEffect
hook can lead to tricky situations. If you’re not careful it can cause unnecessary executions of the effect or even infinite re-renders. Especially when using objects as dependencies.
In this blog post, you'll see four different approaches to using an object as a useEffect
dependency. All have their pros and cons, from being simple yet ineffective, to ugly yet efficient.
Let me walk you through a simplified example I encountered while working on a coding task.
function useGetProducts(filters: Record<string, string>) {useEffect(() => {syncFilters(filters);}, [filters]);// ... rest of the hook}function ProductList() {const products = useGetProducts({ brand: "Nike", color: "red" });return (<div>{products.map((p) => (<ProductCard key={p.id} {...p} />))}</div>);}
Now, at first glance, this looks all fine. However, using the params object as a dependency of the useEffect
is problematic.
Can you see it?
Have a look at how we pass the filters
object from the ProductList
component to theuseGetProducts
hook.
function ProductList() {const products = useGetProducts({ brand: "Nike", color: "red" });...
During each render, this object is created from scratch. The useEffect
internally compares the dependencies by reference. And since the reference to the filters
object is different for each render, the effect would be run with every render as well.
This is less than ideal, but not that easy to spot.
A simple solution is to spread all values of this object as dependencies.
function useGetProducts(filters: Record<string, string>) {useEffect(() => {syncFilters(filters);}, [...Object.values(filters)]);// ... rest of the hook}
Technically, this isn't problematic, because the effect is run whenever one of the values of the params object changes. But we get a lint warning, and would have to disable it to commit our code.
While disabling warnings isn't necessarily a huge problem, this could lead us to forget about a missing dependency later if we, for example, add another filter. This could then lead to a hard-to-find bug.
Another approach is to destructure each filter and pass it as a separate dependency.
function useGetProducts(filters: Record<string, string>) {const { brand, color } = filters;useEffect(() => {syncFilters({ brand, color });}, [brand, color]);// ... rest of the hook}
But this is a bit tedious, and we might forget to add a new parameter if new filters are introduced. Also, in our case, this doesn’t really work as the filters
object could contain any string as key.
Another approach is using this useDeepCompareEffect created by Kent C. Dodds. This hook is similar to the native useEffect
, but instead of comparing the dependencies by reference, it makes a deep comparison of all values inside an object.
Let's give it a try. First, we install the dependency.
npm i use-deep-compare-effect
Then, we replace the useEffect with a new useDeepCompareEffect hook. We can now simply pass the filters
object as a dependency, and the effect won't be run on every render anymore.
function useGetProducts(filters: Record<string, string>) {useDeepCompareEffect(() => {syncFilters(filters);}, [filters]);// ... rest of the hook}
The problem with this hook? When we remove a required dependency like the params object, we don't get a lint warning about missing dependencies.
So, this isn't really better than our destructuring approach before.
A final approach that I saw Dan Abramov recommend somewhere is stringifying the object and parsing it again inside the useEffect
.
function useGetProducts(filters: Record<string, string>) {const json = JSON.stringify(filters);useEffect(() => {const filters = JSON.parse(json);syncFilters(filters);}, [json]);// ... rest of the hook}
This works well with small and not too deeply nested objects that don't contain function values. Honestly, it doesn't look that great, but combines all of the advantages: It decreases the risk of forgetting to add filters and the risk of forgetting to add a dependency in the future. All while keeping the ESLint check intact.
The main problem with this approach is that we lose the type of the filters
object inside the useEffect
.
So inside the useEffect
we need to manually assign the type again.
function useGetProducts(filters: Record<string, string>) {const json = JSON.stringify(filters);useEffect(() => {const filters: Record<string, string> = JSON.parse(json);syncFilters(filters);}, [json]);// ... rest of the hook}
A useEffect
in React can be tricky. Especially when you need to use an object as a dependency. In this blog post we covered 4 techniques to avoid unnecessary executions of the effect by
All approaches have their pros and cons and it depends on the situation which one makes most sense for you.