React 19 data-loading with the use()-hook


Data loading in react have long been a thing handled by external dependencies rather than the library because of the missing ability to handle promises or async/await direclty in JSX and the cumbersome stability checks that needs to be added by you to prevent needless re-fetches/re-renders.

With react@19 this changes since the new use-hook can handle promises and respects suspense boundaries in your code.

Before getting to the gist of how that looks, let’s do a very brief history of how to do data-fetching with react.

Home-rolled custom hook’s for fetch

I’m guessing most apps have had a version of this custom hook for data-fetching at one point. With custom state and an effect that triggers the fetch. It commonly looks like this:

const useFetchJSON = (
    url: string,
    reqInit?: RequestInit
  ) => {

  const [data, setData] = useState(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch(url, reqInit).then(async (res) => {
      if (!res.ok) {
        setIsLoading(false);
        setError(
          new Error('Fetch failed with: ' + res.status)
        );
      } else {
        try {
          setData(await res.json());
        } catch (err) {
          setError(err as Error);
        } finally {
          setIsLoading(false);
        }
      }
    });
  }, [url, reqInit]);

  return { data, error, isLoading };
};

Obvious cons here is that data will be fetched each time the hook is mounted and we have little to no control over re-fetching, retries and caching. Adding all of this will add much complexity. While in many cases this might cause no issues for a deployed app, the recommended <StrictMode> will in this case trigger the fetch twice in development for each mounting of this hook.

A more general variant here would be a custom hook that only took a function that returned a promise. That function needs to be ensured to be stable to avoid multiple fetches, hence the useRef. This approach would make it easier to customize the promise passed in with extra features such as retries, or to make multiple requests inside the single promise etc.

Here implemented with the useReducer-hook instead of 3 different useState-hooks.

const usePromise = <T extends Promise<any>>(promise: () => T) => {
  // ensure promise only executed once (per mount)
  // even if the reference is not "stable"
  const promiseRef = useRef(promise());
  const [state, dispatch] = useReducer(
    (prevState, action) => {
      switch (action.type) {
        case 'error': {
          return {
            data: null,
            error: action.payload,
            isLoading: false
          };
        }
        case 'success': {
          return {
            error: null,
            data: action.payload,
            isLoading: false
          };
        }
      }
      // default
      return prevState;
    },
    { isLoading: true, error: null, data: null }
  );

  useEffect(() => {
      promiseRef.current.then(
        (data) => dispatch({ type: 'success', payload: data }),
        (err) => dispatch({ type: 'error', payload: err })
      );
  }, []);

  return state;
};

Is it better? While certainly more versatile as a custom hook, I’m still missing features for cachability across call-sites and more.

Secondly, none of the above examples support Suspense or ErrorBoundaries (since no errors are thrown) and demand lot’s of attention to details in order to avoid needless re-renders (more than covered in these naive examples).

Using SWR (applies to react-query as well)

Examples only use SWRImmutable since this is the hook I most often go for. it has sane defaults and does not trigger re-fetching ever so often like on page focus state changes etc.

With SWR we move from creating a generic hook for data-fetching to specific hooks per data-type/api-endpoint. SWR ensures that given the same cache-key, the fetcher will only run once however many times the hook is mounted. Many options are available for retries of failed requests, exponential back-off etc.

In this example we’re getting Sanity documents, which makes sense only because on the bonus tip that comes below.

const useSanityData = (
    groqQuery: string,
    swrOptions?: SWROptions
  ) => {

  // deconstructed only for educational purposes
  const { data, isLoading, error, ...rest } =
    useSWRImmutable(groqQuery, async () => {
    const docs = sanityClient.fetch(groqQuery);
    // do something with the docs here?
    // E.g. map, order, filter etc
    return docs; 
  }, swrOptions);

  return { data, isLoading, ...rest };
}

This is really neat for caching and a simple way to do many things inside the fetcher function, maybe I need to make 2 requests for the data I want to return, requests that require specific headers for instance. Mapping can also be done inside the fetcher-function so we really get a ready-to-use result out.

One thing I really like to do, especially for sanity data where some documents might be references for others, is to break out a specific hook for creating a map (or lookup) based on the document id’s like so:

// Cached source of truth for the data
const useSanityDocuments = () => {
  return useSWRImmutable(
    globalGroqQuery,
    () => sanityClient.fetch(globalGroqQuery)
  );
}

// Custom hook that return a cached lookup (Map)
// for the documents
const useSanityDocumentsMap = () => {
  const documents = useSanityDocuments().data;
  return useMemo(() => {
    // Create a Map<string, Document> from the array
    return new Map(
      (documents || []).map(d => ([d._id, d]))
    );
  }, [documents]);
}

// Usage
const sanityDocLookup = useSanityDocumentsMap();

const anotherDoc = sanityDocLookup.get(
  someSanityDoc.propertyWithRef._ref
);

In this way we keep our single source of truth for the documents (useSanityDocuments) and expose only a memoized lookup for simplicity that can be used where we need it without having to feed the sanityDocuments into that hook.

There are plenty more things that make SWR and react-query fantastic to work with but let’s move on to react’s new use()-hook.

react@19 and the use-hook

React 19 introduces the use-hook that accepts a promise (that must not be created inside the same component) and in that way handle async data-calls that automatically uses suspense boundaries and error-boundaries.



const sanityDocumentsPromise = fetch(
  globalGroqQuery
).then(res => res.json());

const Component = () => {

  const data = use(sanityDocumentsPromise);
  // data is available here!!!
}

If you think youäre missing out on features build into SWR and react-query, like retries or types on responses, you can use a fetcher library that includes all of that like ky


// Static data-promise
// started at once this file is loaded
const dataPromise = ky.get(someUrl).json<SomeType>();

const Component = () => {

  const data = use(sanityDocumentsPromise);
  // data is available here,
  // is retried on fails and have types!!!
}

Now this is neat for global static data, or when passing promises from server-components to client-components as props. But we do not usually want to trigger ALL of our data fetches when just parsing the module tree as in this case.

In the SPA case we can fix this by introducing some complexity by creating a (lazy) promise we can trigger manually like so:

const createLazyPromise = <T extends Promise<any>>(asyncFunc: () => T) => {
  const lazyPromise = {} as { promise: T; trigger: () => void };
  let triggered = false;

  lazyPromise.promise = new Promise((resolve, reject) => {
    lazyPromise.trigger = async () => {
      // ensure only a single trigger ever happens
      if (triggered) return;
      triggered = true;
      try {
        const value = await asyncFunc();
        resolve(value);
      } catch (err) {
        reject(err);
      }
    };
  }) as T;

  return lazyPromise;
};

And then we can use it via another custom hook like so:

const slp = createLazyPromise(() => fetch('<some-url>').then(r => r.json()));

const useLazyData = () => {
  slp.trigger();
  return use(slp.promise);
}

const LazyDataComponent = () => {
  const data = useLazyData();
  // data available here
  return (/* */);
}

This approach at least ensures that the data is only ever fetched when the custom hook is mounted, and only once in general, and we get the benefit of Suspense-boundaries:

const App = () => {
  return (
    <Suspense fallback="loading data...">
      <LazyDataComponent />
    </Suspense>
  )
}

Would I use it though? Yeah sure. For some static data that we know at build time this could make sense and provides a cached result type we can use in components without adding too much null-checking and the like.

But this pattern is harder yet to utilize if you ever need to pass in variables to the fetch-call, and all of these concerns are already solved by SWR, react-query and their friends.

So even though this might fit for some situations, giving you the benefits of fewer dependencies if that is a must, I think I’ll still stick to the versitility and stability that SWR gives me for now, in the SPA context.

Links to mentioned libraries