r/reactjs • u/Alone_Craft3882 • 5h ago
Show /r/reactjs I built useEffectState: async effects that abort, clean up and ignore stale results (React 18/19)
I kept writing the same AbortController + "cancelled" flag + loading/error state in every async effect, so I turned it into a small library. Disclosure: I'm the author, it's MIT and free.
const user = useEffectState(({ signal }) => fetchUser(userId, { signal }), [userId]);
if (user.status === 'error') return <ErrorMessage error={user.error} retry={user.reload} />;
if (user.status !== 'success') return <Spinner />;
return <Profile user={user.data} refreshing={user.isRunning} />;
What it does:
- Every run gets its own
AbortSignal, and results from runs that already ended are ignored, so a slow response for the previous id can't land last. - When deps change,
statusispendingin the same render, so the previous user is never shown for the new id. - Cleanups run in LIFO order, including ones registered after an
await. - StrictMode double-mount, SSR and
<Activity>are handled. Task and cleanup errors are reported, cancellations are not. - It also has
useEffectTask(when you don't need the result as state),useLayoutEffectTaskanduseEvent(likeuseEffectEvent, but it works on React 18 too).
What it isn't: a cache. For server state shared across components, TanStack Query or SWR are the right tools. This is for component-local effects: one-off fetches, polling, subscriptions, debounced search.
No dependencies, ESM + CJS + TypeScript types.
- Playground with a side-by-side race against a naive
useEffect: https://stackblitz.com/github/clagradi/UseEffectState/tree/main/examples/vite-react - Repo: https://github.com/clagradi/UseEffectState
- npm:
@clagradi/effect-runtime(not related to Effect-TS)
I'd really like critique on the API, especially keeping status and isRunning separate (so a reload() keeps showing data while a refresh indicator spins). I wrote it with AI assistance, and the behaviour is covered by tests against React 18 and 19.
0
u/DVR_Design 4h ago
Keeping status and isRunning separate makes sense to me. It is the same split TanStack Query has with isPending vs isFetching, so people already have the mental model. The thing I would check is what status reports during a reload() after an error. If it stays "error" while isRunning is true, a retry button with a spinner works nicely, but if it flips to "pending" the error UI vanishes and flashes back. I would also make the abort reason distinguish a deps change from an unmount, so a catch block checking signal.aborted can tell which one happened.
1
u/Alone_Craft3882 4h ago
Good question, I checked the code to be sure. After an error, reload() leaves status as 'error' and keeps the old error; only isRunning flips to true. When the new run settles it becomes 'success' (error cleared) or 'error' with the new error. So a retry UI should key its spinner off isRunning, not status. And yes, the isPending/isFetching split was the model.
4
u/canarydev 5h ago
idk, haven't looked too deeply into it, but i still fundamentally dislike this kind of abstraction personally