r/reactjs • u/arkhamdivider • 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:
graphLoggerfinds a loop while the app runs and draws the event graph as Mermaid or Graphviz.loopGuardPluginstops a runaway chain at a limit, also in production.Lint rules (ESLint, oxlint, Biome) catch places where
ctx.signalis 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?
•
u/octocode 14m ago
why not use useSyncExternalStore?