Avoiding useEffect for fetching in React 18: A use-case for useSyncExternalStore and Observables
Jacob Lowe

Published ● 6 min read

Avoiding useEffect for fetching in React 18: A use-case for useSyncExternalStore and Observables


The problem of double “useEffect” calls

The problem of double useEffect calls in React 18 is caused by a feature in React 18 called “strict mode,” which is a helpful tool that helps you catch issues in your code. This feature causes useEffect to run twice when there are zero dependencies. In prior versions of React, this is how you would run code on mount of a component. This can lead to problems such as sending data fetching requests twice and double tracking page view events. This can cause unexpected behavior in your application.

You can turn off this feature by commenting out <React.StrictMode /> at the entry point, but this also turns off many of React’s useful strict checks. I recommend using this feature, it is another way that React helps you write idiomatic React code, but it is understandable if you want to turn it off.

My first foray with React 18 and strict mode

When upgrading to React 18, in my Mujō Chrome Extension codebase, I encountered a problem with double useEffect calls in my codebase. Specifically, all inter-process communication (IPC) was firing twice. After researching and experimenting, I discovered that the issue was caused by using an IPC request in an useEffect called twice.

To solve this issue, I introduced observables into my library, Finch GraphQL, and updated my React component to listen to the observables using the useSyncExternalStore hook. This allowed me to synchronize my component’s local state with the external store and avoid the double useEffect calls.

Observables are a great way to get notified when data changes happen. In this case, I could hold on to requests and response data from IPC calls in this observable. Once having that data, it was straightforward to subscribe to those changes in a custom hook using useSyncExternalStore.

A double fetch example

Before I go into the implementation of how I updated my code to avoid double useEffect calls successfully, I want to give a brief overview of how the code worked before. In this small example, I create an useFetch hook that fetches data from an API.

In this example, you can see that I am fetching the data in the useEffect hook. This means that when you open up the console, you should see the use effect call happening twice and the API call happening twice. You can see it’s a relatively simple example, which is one of the reasons people use this pattern.

Migrating fetches to observables and useSyncExternalStore

I initially started with a library called RxJS to create observables, but I only needed a small amount of code to make a functioning observable. Having a small footprint, it is easier to comprehend how observers work. Here is what the observable looks like.

// See the full version in the codesandbox below
export class Observable<T = any> {
  //The subscribe method allows for subscribing to changes in the observable value
  public subscribe = (fn: () => void) => { ... }
  // This method allows for the getting of a snapshot of the current value
  public getSnapshot = () => { ... }
  // This method allows for the updating of the observable value
  public update = (value: T) => { ... }
}

Taking that implementation of an observable, I added it to my library—the shape of the data needed to be updated. In the observable, we need to store the loading state of the request. To make the cache aware if we were waiting for data. This update was vital because we didn’t want to make duplicate requests if we were already waiting for data.

new Observer({
  loading: false,
  data: null, // The data from the request
  error: null, // The error from the request
})

Now that we have this observable, we need to update the values based on the fetch loading state and response. This looks something like this.

// See the full version in the codesandbox below
observable.update({
  ...snapshot, // snapshot of data
  loading: true,
})

fetch(...).then(async (resp) => {
  observable.update({
    ...snapshot,
    loading: false,
    data: await resp.json(),
  })
}).catch((err) => {
  observable.update({
    ...snapshot,
    loading: false,
    error: err,
  })
})

Next, we need to hook the observable into the useSyncExternalStore hook to pull the data from the observable into the component. This looks something like this.

useSyncExternalStore(observable.subscribe, () => observable.getSnapshot)

There is one last thing we have to do to make this work. We need a way to look up the existing observable for a given request. In the example, I am just using the URL as the key, but in an actual application, you would want to use a more robust key. This is what the lookup looks like for the example.

const cache = new Map<string, Observable>()

const useFetch = (URL: string) => {
  const observable = useMemo(() => {
    // check for the existing cache
    const existingCache = cache.get(url)
    if (existingCache) return existingCache

    // create a new observable, a fetch data
    const newObservable = new Observable()
    // a function that looks like the code above
    fetchURL(newObservable, URL)
    cache.set(URL, newObservable)
    return newObservable
  }, [url])
  ...

After you put all of this together, you should have something like this.

// See the full version in the codesandbox below
const cache = new Map<string, Observable>()

const fetchData = async <T extends any = {}>(
  url: string,
  options: RequestInit,
  observable: Observable<CacheShape<T>>
) => {
  const snapshot = observable.getSnapshot()
  observable.update({
    ...snapshot,
    loading: true,
  })
  try {
    const res = await fetch(URL, options)
    const json = (await res.json()) as T
    observable.update({
      loading: false,
      error: null,
      data: JSON,
    })
  } catch (error) {
    observable.update({
      ...snapshot,
      loading: false,
      error: error as Error,
    })
  }
}

export const useFetch = <Data extends {} = {}>(
  url: string,
  options: RequestInit = {}
) => {
  const observable = useMemo(() => {
    let existingObservable = cache.get(url) as Observable<CacheShape<Data>>
    if (!existingObservable) {
      const newObservable = new Observable<CacheShape<Data>>({
        loading: false,
        data: null,
        error: null,
      })
      console.log('fetching')
      fetchData<Data>(URL, options, newObservable)
      cache.set(URL, newObservable)
      return newObservable
    }
    return existingObservable
  }, [url])

  return useSyncExternalStore(observable.subscribe, () =>
    observable.getSnapshot()
  )
}

Here is the complete example in a codesandbox.

In the sandbox, you will see all the code above integrated, and you should be able to open the dev tools to see that the API is only called once. It may be more code that the useEffect example, but is a great pattern that can be applied to many different use cases.

Conclusion

By migrating to observables and using the useSyncExternalStore hook, I was able to avoid the double useEffect calls caused by strict mode in React 18. Not only did this solve the problem of double data fetching, but it also made my code more robust and maintainable. I highly recommend using observables and the useSyncExternalStore hook in your React applications, especially when dealing with external data sources.

You can see the full code of this example in this codesandbox and the updated version of my library Finch GraphQL on GitHub.

I hope this blog post helps you avoid the double useEffect calls caused by strict mode in React 18 and shows you the benefits of using observables and the useSyncExternalStore hook in your React applications.

Edit on Github