Integrations
•7 min read
Directive + Redux
Redux handles predictable state with reducers, actions, and devtools. Directive adds constraint-driven async orchestration – requirements that evaluate automatically and resolvers that fulfill them.
Prerequisites
This guide assumes familiarity with Core Concepts and Module & System. Need to install first? See Installation.
Migrating from Redux?
Want to replace Redux entirely? See the Redux to Directive migration guide for step-by-step codemods and concept mapping.
Why Use Both
Redux gives you predictable state management: immutable updates, action history, time-travel debugging through Redux DevTools, and a massive ecosystem of middleware.
Directive adds a constraint layer that evaluates across your Redux state automatically. Instead of writing thunks or sagas that manually check conditions and dispatch actions, you declare constraints that fire when conditions are met and resolvers that handle the async work.
Together:
- Redux owns UI state: reducers, selectors, action history, DevTools
- Directive owns orchestration: constraints evaluate your Redux state, resolvers handle async side effects with retry and error recovery
- Replace complex thunk chains and saga flows with declarative constraints
Redux → Directive
Subscribe to your Redux store and batch-write slices into Directive facts.
Redux subscribe receives NO arguments
Unlike Zustand, Redux's store.subscribe(listener) passes no arguments to the listener. You must call store.getState() inside the callback to read current state.
import { store } from './redux-store';
function syncReduxToDirective(state: RootState, prevState: RootState) {
system.batch(() => {
if (state.auth.user !== prevState.auth.user) {
system.facts.user = state.auth.user;
}
if (state.cart.items !== prevState.cart.items) {
system.facts.cartItems = state.cart.items;
}
if (state.cart.total !== prevState.cart.total) {
system.facts.cartTotal = state.cart.total;
}
});
}
// Sync current state immediately so facts aren't stale until first change
let prevState = store.getState();
syncReduxToDirective(prevState, {} as RootState);
const unsubscribe = store.subscribe(() => {
const state = store.getState();
syncReduxToDirective(state, prevState);
prevState = state;
});
// Clean up when done: unsubscribe()
Selective sync avoids unnecessary derivation recomputation. Only write facts that actually changed.
Directive → Redux
Watch Directive facts and dispatch Redux actions when they change:
import { orderActions } from './redux-store';
const unwatch = system.watch('orderStatus', (status, prev) => {
store.dispatch(orderActions.setStatus(status));
});
// Clean up when done: unwatch()
For RTK slices, use the generated action creators directly:
system.watch('discountApplied', (discount) => {
store.dispatch(cartSlice.actions.applyDiscount(discount));
});
system.watch('shippingEstimate', (estimate) => {
store.dispatch(cartSlice.actions.setShipping(estimate));
});
Directive as Redux Middleware
Forward every Redux action to Directive as an event. This lets constraints react to Redux actions directly:
import type { Middleware } from 'redux';
const directiveMiddleware: Middleware = (api) => (next) => (action) => {
// Let Redux process the action first
const result = next(action);
// Forward to Directive as an event
if (typeof action === 'object' && action !== null && 'type' in action) {
try {
system.dispatch({ type: action.type, payload: (action as any).payload });
} catch (err) {
// Directive may not have a handler for every Redux action – that's expected.
// Log in development so real errors aren't hidden.
if (process.env.NODE_ENV === 'development') {
console.warn(`[directive-middleware] Failed to dispatch ${action.type}:`, err);
}
}
}
return result;
};
// Apply middleware
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(directiveMiddleware),
});
Now your Directive module can define event handlers that respond to Redux actions:
events: {
'cart/addItem': (facts, payload) => {
facts.lastCartAction = 'add';
},
'cart/removeItem': (facts, payload) => {
facts.lastCartAction = 'remove';
},
},
Constraint-Driven Side Effects
Replace thunks with Directive constraints. When Redux state (synced as facts) meets a condition, the constraint fires and a resolver handles the async work:
import { createModule, t } from '@directive-run/core';
const cartModule = createModule('cart', {
schema: {
facts: {
cartItems: t.array(t.object()),
cartTotal: t.number(),
user: t.object(),
discountApplied: t.boolean(),
},
derivations: {
eligibleForFreeShipping: t.boolean(),
},
events: {},
requirements: {
APPLY_DISCOUNT: { discount: t.string() },
},
},
init: (facts) => {
facts.cartItems = [];
facts.cartTotal = 0;
facts.user = null;
facts.discountApplied = false;
},
derive: {
eligibleForFreeShipping: (facts) =>
facts.cartTotal > 100 && facts.user?.tier === 'premium',
},
constraints: {
freeShipping: {
when: (facts) =>
facts.eligibleForFreeShipping && !facts.discountApplied,
require: () => ({ type: 'APPLY_DISCOUNT', discount: 'FREE_SHIPPING' }),
},
},
resolvers: {
applyDiscount: {
requirement: 'APPLY_DISCOUNT',
key: (req) => `discount-${req.discount}`,
retry: { attempts: 3, backoff: 'exponential' },
resolve: async (req, context) => {
const result = await api.applyDiscount(req.discount);
context.facts.discountApplied = true;
// Push result back to Redux
store.dispatch(cartActions.setDiscount(result));
},
},
},
});
The constraint fires automatically when cartTotal > 100 and the user is premium. The resolver handles the API call with retry. No thunk, no saga – just a declaration.
Plugin: Mirror to Redux DevTools
Use a plugin to dispatch Directive fact changes as Redux actions, making them visible in Redux DevTools:
import type { Plugin } from '@directive-run/core';
const reduxDevtoolsPlugin: Plugin = {
name: 'redux-devtools-mirror',
onFactSet: (key, value, prev) => {
store.dispatch({ type: `directive/${key}`, payload: value });
},
onResolverStart: (resolver, req) => {
store.dispatch({
type: `directive/resolver/${resolver}/start`,
payload: { requirement: req.type },
});
},
onResolverComplete: (resolver, req, duration) => {
store.dispatch({
type: `directive/resolver/${resolver}/complete`,
payload: { requirement: req.type, duration },
});
},
onResolverError: (resolver, req, error) => {
store.dispatch({
type: `directive/resolver/${resolver}/error`,
payload: { requirement: req.type, error: String(error) },
});
},
};
Now every Directive state change and resolver lifecycle event shows up in Redux DevTools alongside your Redux actions.
React Integration
Wire both stores in a React component using useEffect for subscription lifecycle:
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useDirectiveRef } from '@directive-run/react';
function CartPage() {
// useDirectiveRef returns the system directly (useDirective returns reactive selections)
const system = useDirectiveRef(cartModule);
const reduxCart = useSelector((state) => state.cart);
// Sync Redux → Directive
useEffect(() => {
let prevCart = store.getState().cart;
const unsubscribe = store.subscribe(() => {
const state = store.getState();
if (state.cart !== prevCart) {
system.batch(() => {
system.facts.cartItems = state.cart.items;
system.facts.cartTotal = state.cart.total;
});
prevCart = state.cart;
}
});
return () => unsubscribe();
}, [system]);
// Sync Directive → Redux
useEffect(() => {
const unwatch = system.watch('discountApplied', (applied) => {
if (applied) {
store.dispatch(cartActions.markDiscounted());
}
});
return () => unwatch();
}, [system]);
return (
<div>
<p>Total: ${reduxCart.total}</p>
<p>Discount: {system.facts.discountApplied ? 'Applied' : 'None'}</p>
</div>
);
}
SSR / Next.js
For server-side rendering, see Advanced: SSR & Hydration for how to serialize and restore both stores during hydration.
Avoiding Infinite Loops
When syncing bidirectionally, prevent infinite loops with a guard:
let syncing = false;
// Redux → Directive
store.subscribe(() => {
if (syncing) {
return;
}
syncing = true;
const state = store.getState();
system.batch(() => {
system.facts.count = state.counter.value;
});
syncing = false;
});
// Directive → Redux
system.watch('count', (value) => {
if (syncing) {
return;
}
syncing = true;
store.dispatch(counterActions.set(value));
syncing = false;
});
Alternatively, use equalityFn on system.watch to skip redundant updates:
system.watch('count', (value) => {
if (value !== store.getState().counter.value) {
store.dispatch(counterActions.set(value));
}
});
Testing
Test the integration using Directive's test utilities alongside a real or mock Redux store:
import { configureStore } from '@reduxjs/toolkit';
import { createTestSystem } from '@directive-run/core/testing';
test('constraint fires when Redux state synced', async () => {
const testSystem = createTestSystem({ module: cartModule });
testSystem.start();
// Simulate Redux state arriving
testSystem.batch(() => {
testSystem.facts.cartTotal = 150;
testSystem.facts.user = { tier: 'premium' };
testSystem.facts.discountApplied = false;
});
// Constraint should fire and produce a requirement
await testSystem.waitForIdle();
expect(testSystem.allRequirements).toContainEqual(
expect.objectContaining({
requirement: expect.objectContaining({ type: 'APPLY_DISCOUNT' }),
})
);
});
Next Steps
- Migration from Redux – Full migration guide if you want to move off Redux entirely
- Constraints – How constraints evaluate and emit requirements
- Resolvers – How resolvers fulfill requirements with retry and batching
- Plugins – Build custom plugins for cross-cutting concerns

