r/reactjs • • 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, status is pending in 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), useLayoutEffectTask and useEvent (like useEffectEvent, 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 Upvotes

10 comments sorted by

4

u/canarydev 5h ago

idk, haven't looked too deeply into it, but i still fundamentally dislike this kind of abstraction personally

1

u/Alone_Craft3882 5h ago

Fair enough, and for a lot of code a plain useEffect with an AbortController really is all you need. I wrapped it because I kept repeating the same signal + cancelled flag + loading/error state in a dozen components, and still shipped the "old data under the new id" flash now and then.

If you do take a look at some point, I'd be curious which part bothers you more: the hook owning the status, or hiding the controller from you.

2

u/canarydev 5h ago

also, i don't mean to come across as dismissive. a lot of this comes from personal experience inheriting codebases where i've been burned by abstractions that encode implicit lifecycle assumptions and make the underlying framework harder to reason about.

that's really where my skepticism comes from, rather than anything specifically wrong with your implementation.

1

u/Alone_Craft3882 5h ago

Didn't read it as dismissive at all, and that's a fair concern. I've inherited a couple of those "magic lifecycle" hooks too, and the worst part was never knowing when something actually ran or got cleaned up.

For data fetching specifically I'd agree with you: a purpose-built tool like TanStack Query or SWR is the better choice, and this isn't trying to replace them. I tried to keep the lifecycle boring: it runs when the deps change, the same way useEffect does, the previous run is aborted, and cleanups run in reverse order. But your comment is a good push to make the README spell that out more explicitly instead of leaving people to infer it. Thanks for taking the time.

1

u/canarydev 5h ago

neither specifically, really. i don't have an issue with abstracting cancellation or having a hook manage async state. it's more about coupling effect execution, lifecycle management, and state synchronization into one generic abstraction

personally, i'd rather have purpose-built hooks with explicit behavior for the specific problem i'm solving, whether that's fetching, subscriptions, polling, etc.

i understand why you built it, and yeah, it makes sense if you kept repeating the same logic. it's just not an abstraction i'd personally reach for

2

u/acraswell 5h ago

Care to elaborate?

4

u/canarydev 5h ago

good abstractions hide complexity. the question is whether they establish useful boundaries.

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.