Frontend Development 13 min read

Using React’s Built‑in Features to Handle Loading and Error States with Promises

This article explains how to display loading and error states in React by passing Promise objects through props, context, or state libraries, leveraging Suspense, ErrorBoundary, and custom hooks such as usePromise and use to simplify asynchronous UI patterns while avoiding unnecessary re‑renders and side‑effects.

ByteFE
ByteFE
ByteFE
Using React’s Built‑in Features to Handle Loading and Error States with Promises

Preface

Traditional network requests create a Promise and immediately consume it with useEffect, converting the Promise into loading, data, or error state.

Display loading & error states using React’s own capabilities

// ❌️
export default () => {
    return error ?
error
: loading ?
loading
: data
}
// ✅️ Suspense & ErrorBoundary can be used without being placed in the current component.
export default () => {
    return (
loading
}>
error
}>
                ...
);
}

How to obtain Promise loading & error states

// ❌️ Manual state handling
export default () => {
    const [error, setError] = useState();
    const [loading, setLoading] = useState();
    const [data, setData] = useState();
    useEffect(() => {
        setLoading(true);
        fetcher('xxx').then(data => {
            setLoading(false);
            setData(data);
        }).catch(error => {
            setError(error);
        });
    }, []);
    return error ?
error
: loading ?
loading
: data;
}
// ✅️ Pass the Promise directly to Suspense/Await
const promise = fetcher('xxx');
export default () => {
    return (
loading
}>
error
}>
);
}

Who is using this? ReactRouter

https://reactrouter.com/en/main/components/await

The router’s way of consuming a Promise is not optimal, but the key idea is to pass the Promise itself via props instead of the resolved data.

Advantages

First‑screen network request is no longer a side effect

// ❌️ Re‑render triggers request again
export default () => {
    const resultP = useRef(fetcher('xxx'));
}
// ✅️ Store the Promise in state lazily
export default () => {
    const [resultP] = useState(() => fetcher('xxx'));
}

How to re‑trigger a request – just store a new Promise in state

export default () => {
    const [resultP, setResultP] = useState(() => fetcher('xxx'));
    return (
        <>
setResultP(fetcher(filter))} />
loading
}>
error
}>
);
}

Solving async race conditions elegantly

https://juejin.cn/post/7225885023822463031

// ❌️ Multiple filter changes cause race conditions
export default () => {
    const [filterCondition, setFilterCondition] = useState();
    const [error, setError] = useState();
    const [loading, setLoading] = useState();
    const [data, setData] = useState();
    useEffect(() => {
        setLoading(true);
        fetcher(filterCondition).then(data => {
            setLoading(false);
            setData(data);
        }).catch(error => setError(error));
    }, [filterCondition]);
    return (
        <>
setFilterCondition(c)} />
            {error ?
error
: loading ?
loading
: data}
        
    );
}

Deferring the first‑screen request to the router so it does not block navigation

// ❌️ loader blocks page entry
createBrowserRouter([
  {
    element:
, 
    path: "teams",
    loader: async () => {
        return await fetcher('xxx');
    }
  },
]);
// ✅️ defer the Promise
createBrowserRouter([
  {
    element:
, 
    path: "teams",
    loader: () => {
        return defer({ resultP: fetcher('xxx') });
    }
  },
]);

Why the React community introduced a new API for first‑class Promises

https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md

// unstable API example
import { use } from 'react';
const ChildComponent = () => {
    const [resultP, setResultP] = useState();
    const data = use(resultP);
    return (
        <>
setResultP(fetcher(c))} />
);
};
export default () => (
loading
}>
error
}>
);

Usage

Passing Promise between parent and child just like normal state

// ✅ Pass promise via props
// ✅ Pass promise via React Context
// ✅ Store promise in a state library

Combine with a state‑management library (e.g., reduck)

const fetcherModel = model('fetcher');
const FilterForm = () => {
    const [state, actions] = useModel(fetcherModel);
    return (
actions.setState(fetcher('xxx'))}>x
);
};
const ShowData = () => {
    const [state] = useModel(fetcherModel);
    return (
loading
}>
error
}>
);
};

Advanced Topics

Implementing the new API when your React/ReactRouter version is too old

// Implementation based on loadable
import loadable from "@loadable/component";
import React, { createContext, useContext } from "react";
const AsyncDataContext = createContext(undefined);
export const Await = loadable(async (props) => {
    const { resolver } = props;
    const data = await resolver;
    return (p) => {
        const { children } = p;
        if (typeof children === "function") {
            return children(data);
        }
        return (
{children}
);
    };
}, { cacheKey: ({ resolver }) => resolver });
export const useAsyncValue = () => useContext(AsyncDataContext);

Implementing the use() API

function use(promise) {
    if (promise.status === 'fulfilled') {
        return promise.value;
    } else if (promise.status === 'rejected') {
        throw promise.reason;
    } else if (promise.status === 'pending') {
        throw promise;
    } else {
        promise.status = 'pending';
        promise.then(
            result => { promise.status = 'fulfilled'; promise.value = result; },
            reason => { promise.status = 'rejected'; promise.reason = reason; }
        );
        throw promise;
    }
}

Creating a usePromise hook that tracks loading, data and error

type PromiseCanUse
= Promise
& {
    status?: 'pending' | 'fulfilled' | 'rejected';
    reason?: unknown;
    value?: T;
};
function usePromise
(promise?: PromiseCanUse
) {
    const [, forceUpdate] = useState({});
    const ref = useRef
>();
    if (!promise) return { loading: false, data: undefined };
    ref.current = promise;
    if (!promise.status) {
        promise.status = 'pending';
        promise.then(
            result => { promise.status = 'fulfilled'; promise.value = result; },
            reason => { promise.status = 'rejected'; promise.reason = reason; }
        ).finally(() => {
            setTimeout(() => { if (ref.current === promise) forceUpdate({}); }, 0);
        });
    }
    return {
        loading: promise.status === 'pending',
        data: promise.value,
        error: promise.reason,
    };
}

Summary

Traditional network requests create a Promise and immediately consume it with useEffect, turning it into loading, data or error state.

Now you should pass the Promise itself between components, using Suspense/ErrorBoundary or custom hooks to handle loading and error without extra side‑effects.

⭐️⭐️⭐️ Use in your project

// Export a single file with usePromise, use, and <Await />
import { createContext, useContext, useRef, useState } from 'react';
// ... (implementation omitted for brevity)

Miscellaneous

You really know how to use await? Your code may be hurting performance

// ❌️ Sequential awaits
export default async () => {
    await fetcher('some1');
    await fetcher('some2');
}
// ✅️ Parallel execution
export default async () => {
    return Promise.all([fetcher('some1'), fetcher('some2')]);
}

Notes

use(promise) works correctly only when the same Promise instance is used before and after loading; using use(Promise.all(...)) creates a new Promise on each render and will keep triggering the loading state. Wrap such calculations in useMemo.

Further Reading

https://react.dev/learn/you-might-not-need-an-effect

frontendreactPromiseError BoundaryLoading StateSuspense
ByteFE
Written by

ByteFE

Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.