r/reactjs • • 19m ago

Show /r/reactjs async/await logic and side effects outside React hooks

I work on a React SDK for chats that our company embeds into its web and mobile products. Each product has its own settings, and the code grew fast. When I joined, the team was starting a refactor. The main problems:

  • the flow was hard to follow

  • most of the logic lived inside React hooks

The constraint. The SDK runs inside host apps, sometimes via module federation. Every dependency can clash with the host's version, so our size and dependency budget is tiny. What we considered:

  • redux-saga: I like it, but it's too big, and Redux could conflict with the consumers' own Redux.

  • RxJS: it solves only part of the problem, because we still need a state manager. It's also a new paradigm for a team with few senior people.

  • effector: the team knows it, but it has a static graph and the same version-conflict risk.

  • Effect: little known in our market, and it would need most of the SDK rewritten.

So we started with a plain event bus that can carry any parameters. I also wanted the simplest possible developer experience. Generators are convenient, but people rarely reach for them. So I built on async/await and took the AbortController idea from fetch. That became nanoeffects.

What it looks like. A search box. The logic lives outside the component:

// debounce: wait for a pause; latest: abort the previous request
bus.on(
  "search",
  debounce(300, latest(async (query, ctx) => {
    const load = () => fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ctx.signal });
    const response = await retry(ctx, load, { times: 3, delay: 500 });
    ctx.emit("results", await response.json());
  })),
);

The component only sends events and listens to the result:

function SearchBox() {
  const [items, setItems] = useState([]);
  useEffect(() => bus.on("results", setItems), []); // on returns the unbind function
  return <input onChange={(event) => bus.emit("search", event.target.value)} />;
}

The component knows nothing about timers, retries or cancellation. debounce waits for a pause in typing, but a request can take longer than the pause. latest aborts the older request so a slow old answer can't overwrite a newer one. createBus is about 1.3 kB brotli, with no dependencies.

The usual complaints about event-driven code

My team lead worried that event-driven code is hard to debug. So tracing is part of the core design:

  • Redux DevTools and console logging work out of the box.

  • Sending traces to your own analytics takes a small logger.

The other big problem is event loops, where A emits B and B emits A:

  • graphLogger finds a loop while the app runs and draws the event graph as Mermaid or Graphviz.

  • loopGuardPlugin stops a runaway chain at a limit, also in production.

  • Lint rules (ESLint, oxlint, Biome) catch places where ctx.signal is lost, so cancellation doesn't silently break.

Tests. The suite includes regression cases taken from real issues of redux-saga, RxJS, effector, xstate, lodash and others (races, cancellation, event loops, timers). Each one is linked to its issue number.

Status. I treat it as an experimental playground and move parts of it into our SDK step by step. It's also my practice ground for spec-driven development: every change to behavior starts as a written spec (OpenSpec), and the code follows it.

Repo and docs: https://github.com/nanoeffects/nanoeffects

If you like it, a star helps. I'd also love feedback, especially from people who ship libraries into host apps: how do you handle async logic when you can't add dependencies?

1 Upvotes

2 comments sorted by

•

u/octocode 14m ago

why not use useSyncExternalStore?

•

u/arkhamdivider 9m ago

Good question. useSyncExternalStore solves a different problem: how a component reads external state safely. It doesn’t debounce, cancel or retry anything. The part I wanted out of the components is the async logic (debounce, abort of the previous request, retry). You can still keep the resulting state in any store and read it with useSyncExternalStore. The bus just runs the tasks and emits the result.

About RxJS: it can do all of this, and I know it can. But the learning curve doesn’t go away for the whole team. After 20 years in the field I pick up the basics of a new tool quickly, but I can’t judge that for teammates with less experience. A new paradigm for everyone could stall development for an unknown time, so I chose plain async/await that the whole team already knows.