@clagradi/effect-runtime is a small React 18+ primitive for safer async effects.
npm i @clagradi/effect-runtimepnpm add @clagradi/effect-runtimeyarn add @clagradi/effect-runtime- Per-run
AbortController(auto abort on rerun/unmount) commit()anti-race guard for stale async completions- Late async cleanup is still executed after dispose
onCleanup()supports multiple cleanups with LIFO orderuseEventgives stable callbacks without stale closures- StrictMode-safe effect lifecycle behavior
Before:
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((response) => response.json())
.then((user) => {
if (!cancelled) {
setName(user.name);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [userId]);After:
useEffectTask(
async ({ signal, commit }) => {
const response = await fetch(`/api/users/${userId}`, { signal });
const user = await response.json();
commit(() => {
setName(user.name);
});
},
[userId]
);Uses signal + commit so overlapping requests do not commit stale state.
useEffectTask(
async ({ signal, commit }) => {
const response = await fetch(`/api/todos/${todoId}`, { signal });
const todo = await response.json();
commit(() => {
setTodo(todo);
});
},
[todoId]
);Uses useEvent so an interval sees the latest state without growing dependency arrays.
const onTick = useEvent(() => {
setCount((value) => value + 1);
console.log(label);
});
useEffectTask(({ onCleanup }) => {
const handle = setInterval(() => onTick(), 1000);
onCleanup(() => clearInterval(handle));
}, []);Uses onCleanup to pair subscribe / unsubscribe logic per run.
useEffectTask(({ onCleanup }) => {
const unsubscribe = subscribeToRoom(roomId, (message) => {
setMessages((prev) => [message, ...prev].slice(0, 5));
});
onCleanup(() => unsubscribe());
}, [roomId]);cd examples/vite-react
npm install
npm run devThen open the local URL printed by Vite, usually http://localhost:5173.
Try it online:
- StackBlitz:
TODO - CodeSandbox:
TODO
The example app depends on the published package, and the release smoke test temporarily installs the packed tarball on top of it. Examples are not published to npm because the root package ships only dist/.
export function useEvent<T extends (...args: any[]) => any>(fn: T): T;
type EffectTaskScope = {
signal: AbortSignal;
runId: number;
isActive(): boolean;
commit(fn: () => void): void;
onCleanup(fn: () => void): void;
};
type EffectTask =
(scope: EffectTaskScope) =>
void | (() => void) | Promise<void | (() => void)>;
export function useEffectTask(
task: EffectTask,
deps: any[],
options?: { layout?: boolean; onError?: (err: unknown) => void; debugName?: string }
): void;- Not a data-fetching cache. This is not React Query or SWR.
- No caching or SSR orchestration.
- It helps effect correctness; it does not replace application data architecture.
Run the real consumer smoke test used by CI:
npm run smoke:consumerWhat it does:
- builds
dist/from source - builds the library tarball with
npm pack - installs that tarball into
examples/vite-react - runs the example app
typecheck - runs the example app
build
You can also do it manually:
npm run build
npm pack --pack-destination .tmp
cd examples/vite-react
npm install --package-lock=false
npm install --no-save --package-lock=false ../../.tmp/clagradi-effect-runtime-<version>.tgz
npm run typecheck
npm run build- Validate the repo:
npm run typecheck
npm run test
npm run build
npm run smoke:consumer- Bump the version and create the release tag:
npm version patch- Push
mainand the tag:
git push origin main
git push origin --tags- GitHub Actions publishes on tags matching
v*using:
npm publish --provenance --access public- Local manual publish remains available, but CI publish is the preferred path. If local npm tries to generate provenance outside a supported provider, use:
npm publish --access public --provenance=false