A dependency-aware event orchestration library for React + Redux Toolkit. Define execution plans as directed acyclic graphs (DAGs), and eventiq handles scheduling, dependency resolution, concurrent execution, and lifecycle tracking.
eventiq implements an incremental variant of Kahn's algorithm, executed reactively across Redux reducer and middleware cycles:
Modern applications involve complex async workflows — interdependent API calls, ordered initialization sequences, and coordinated loading states. eventiq provides a declarative approach to defining these workflows as dependency graphs, automatically resolving execution order and maximizing concurrency.
- Declarative dependency graphs — define relationships between events, not execution order
- Automatic concurrent scheduling — events execute as soon as their dependencies resolve, independent events run in parallel
- DAG validation — circular dependencies, duplicate event names, and missing dependency references are caught at plan submission time
- Redux-native — integrates as a standard reducer + listener middleware
- React hooks — subscribe to event lifecycle directly from components
- Type-safe — fully generic over plan and event names
npm install @chitova263/eventiq@0.0.1Peer dependencies: react >= 18, react-dom >= 18, @reduxjs/toolkit >= 2
// store.ts
import { configureStore } from '@reduxjs/toolkit';
import { createEventiq } from 'eventiq';
import { apiSlice } from './apiSlice';
type EventName = 'fetch-user' | 'fetch-posts' | 'fetch-analytics';
type PlanName = 'profile-load';
export const eventiq = createEventiq<PlanName, EventName>();
export const store = configureStore({
reducer: {
eventiq: eventiq.reducer,
api: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ serializableCheck: false })
.prepend(eventiq.listener.middleware),
});import type { ExecutionPlan } from 'eventiq';
const profilePlan: ExecutionPlan<PlanName, EventName> = {
name: 'profile-load',
events: [
{ name: 'fetch-user', needs: [] },
{ name: 'fetch-posts', needs: ['fetch-user'] },
{ name: 'fetch-analytics', needs: ['fetch-posts'] },
],
};Listeners handle all async work — API calls, state updates, and eventiq signaling.
// actions.ts
import { createAction } from '@reduxjs/toolkit';
export const fetchUser = createAction('api/fetchUser');
export const fetchPosts = createAction('api/fetchPosts');
export const fetchAnalytics = createAction('api/fetchAnalytics');// listeners.ts
import { eventiq, store } from './store';
import { apiSlice } from './apiSlice';
import { fetchUser, fetchPosts, fetchAnalytics } from './actions';
import * as api from './api';
eventiq.listener.startListening({
actionCreator: fetchUser,
effect: async (_, listenerApi) => {
try {
const user = await api.getUser();
listenerApi.dispatch(apiSlice.actions.setUser(user));
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-user', outcome: 'SUCCESS' }));
} catch {
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-user', outcome: 'FAILURE' }));
}
},
});
eventiq.listener.startListening({
actionCreator: fetchPosts,
effect: async (_, listenerApi) => {
try {
const { user } = listenerApi.getState().api;
const posts = await api.getPosts(user!.id);
listenerApi.dispatch(apiSlice.actions.setPosts(posts));
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-posts', outcome: 'SUCCESS' }));
} catch {
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-posts', outcome: 'FAILURE' }));
}
},
});
eventiq.listener.startListening({
actionCreator: fetchAnalytics,
effect: async (_, listenerApi) => {
try {
const { posts } = listenerApi.getState().api;
const analytics = await api.getAnalytics(posts!.map(p => p.id));
listenerApi.dispatch(apiSlice.actions.setAnalytics(analytics));
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-analytics', outcome: 'SUCCESS' }));
} catch {
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-analytics', outcome: 'FAILURE' }));
}
},
});Components only dispatch — no async logic, no direct API calls.
// ProfilePage.tsx
import { useDispatch } from 'react-redux';
import { eventiq } from './store';
import { fetchUser, fetchPosts, fetchAnalytics } from './actions';
function ProfilePage() {
const dispatch = useDispatch();
eventiq.useEventStarted('fetch-user', () => dispatch(fetchUser()));
eventiq.useEventStarted('fetch-posts', () => dispatch(fetchPosts()));
eventiq.useEventStarted('fetch-analytics', () => dispatch(fetchAnalytics()));
return (
<button onClick={() => dispatch(eventiq.actions.planSubmitted(profilePlan))}>
Load Profile
</button>
);
}The flow:
- Component dispatches
planSubmitted. eventiq marksfetch-userasREADY(no dependencies). - The scheduler fires
startedforfetch-user. TheuseEventStartedhook dispatchesfetchUser(). - The listener catches
fetchUser, calls the API, stores the result, and dispatchescompleted. completedwithSUCCESSunblocksfetch-posts. The scheduler starts it, the hook dispatchesfetchPosts(), and the listener takes over again.- This continues until all events complete. If any listener catches an error,
FAILUREstops that branch.
eventiq implements an incremental variant of Kahn's algorithm, executed reactively across Redux reducer and middleware cycles:
planSubmitted→ the reducer converts plan events intoExecutableEventobjects. Events with no dependencies are markedREADY, others areBLOCKED.- A listener middleware reacts to
planSubmittedandcompletedactions → finds allREADYevents → dispatches internalstartedactions for each. started→ the reducer transitions the event toRUNNING. TheuseEventStartedhook fires the user-provided callback.- User code completes work and dispatches
completed({ name, outcome }). If the outcome isSUCCESSorSKIPPED, the reducer marks the eventCOMPLETEand unblocks dependants whose needs are now all met. - Newly unblocked events become
READY, the listener picks them up, and the cycle continues until the DAG is fully resolved.
This reactive approach provides maximum concurrency — independent events run in parallel without any pre-computed ordering step.
On planSubmitted, eventiq validates the execution plan before it enters the queue. The following conditions throw synchronously in the reducer:
| Validation | Error |
|---|---|
| Duplicate event names | Duplicate event name "X" |
| Reference to undefined dependency | Event "X" depends on "Y" which doesn't exist in the plan |
| Circular dependencies (Kahn's algorithm) | Circular dependency detected among events: [X, Y, Z] |
These checks ensure only valid DAGs are scheduled.
Use the outcome field on completed to control pipeline behavior:
eventiq.listener.startListening({
actionCreator: fetchPosts,
effect: async (_, listenerApi) => {
try {
const posts = await api.getPosts(userId);
listenerApi.dispatch(apiSlice.actions.setPosts(posts));
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-posts', outcome: 'SUCCESS' }));
} catch {
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-posts', outcome: 'FAILURE' }));
}
},
});| Outcome | Effect on dependants |
|---|---|
SUCCESS |
Dependants are unblocked |
SKIPPED |
Dependants are unblocked (treated as a successful completion) |
FAILURE |
Dependants remain BLOCKED — the pipeline halts on that branch |
Skip an event based on runtime state. Downstream events still unblock:
eventiq.listener.startListening({
actionCreator: fetchPremiumContent,
effect: async (_, listenerApi) => {
const { user } = listenerApi.getState().api;
if (!user!.isPremium) {
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-premium-content', outcome: 'SKIPPED' }));
return;
}
const content = await api.getPremiumContent(user!.id);
listenerApi.dispatch(apiSlice.actions.setPremiumContent(content));
listenerApi.dispatch(eventiq.actions.completed({ name: 'fetch-premium-content', outcome: 'SUCCESS' }));
},
});Creates an eventiq instance. Returns:
| Property | Type | Description |
|---|---|---|
actions.planSubmitted(plan) |
ActionCreator |
Submit an execution plan to the queue |
actions.completed({ name, outcome }) |
ActionCreator |
Signal event completion with outcome |
reducer |
Reducer |
Redux reducer — mount at state.eventiq |
listener |
ListenerMiddlewareInstance |
RTK listener middleware — prepend to middleware chain |
selectors.selectQueue(state) |
Selector |
Select the execution queue |
selectors.selectReadyEvents(state) |
Selector |
Select events in READY status |
useEventStarted(name, callback) |
Hook |
Fires when an event begins executing |
useEventSucceeded(name, callback) |
Hook |
Fires when internal scheduling marks an event succeeded |
type ExecutionPlan<TPlanName, TEventName> = {
name: TPlanName;
events: PlanEvent<TEventName>[];
};
type PlanEvent<TEventName> = {
name: TEventName;
needs: TEventName[]; // dependencies that must complete before this event starts
};type ExecutionOutcome = 'SUCCESS' | 'FAILURE' | 'SKIPPED';IDLE → READY (no deps) or BLOCKED (has deps)
BLOCKED → READY (when all needs complete with SUCCESS or SKIPPED)
READY → RUNNING (scheduler picks up)
RUNNING → COMPLETE (user dispatches completed)
| Status | Description |
|---|---|
IDLE |
Initial state during plan construction |
BLOCKED |
Waiting on one or more dependencies to complete |
READY |
All dependencies satisfied, queued for execution |
RUNNING |
Currently executing user-provided callback |
COMPLETE |
Finished — check outcome for SUCCESS, FAILURE, or SKIPPED |
{
eventiq: {
queue: ExecutablePlan[];
isQueueHandlingException: boolean;
}
}Each ExecutablePlan contains ExecutableEvent objects:
type ExecutableEvent<TEventName> = {
id: string;
name: TEventName;
status: ExecutionStatus;
outcome: ExecutionOutcome | null;
needs: ExecutableEvent<TEventName>[];
dependants: ExecutableEventDependant<TEventName>[];
startTime: number | null;
endTime: number | null;
};The demo/ directory contains a working example:
- API Orchestration — a profile page where mock API calls depend on each other's results, demonstrating fan-out from a single root event
Includes a live pipeline visualization and a store inspector panel.
cd demo
npm install
npm run devMIT