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.
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 libraryCombine 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
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.