Guides
•4 min read
How to Connect to WebSockets
WebSocket lifecycle via effects, automatic reconnection via constraints, and message dispatching via facts.
The Problem
WebSocket connections need careful lifecycle management: open on mount, close on unmount, reconnect on disconnect, buffer messages during reconnection, and dispatch incoming messages to the right handlers. Imperative approaches scatter this across component lifecycle methods, leading to leaked connections, lost messages, and reconnection loops.
The Solution
import { createModule, t } from '@directive-run/core';
const ws = createModule('ws', {
schema: {
url: t.string(),
status: t.string<'disconnected' | 'connecting' | 'connected' | 'error'>(),
lastMessage: t.object<{ type: string; payload: unknown }>().optional(),
retryCount: t.number(),
maxRetries: t.number(),
},
init: (facts) => {
facts.url = '';
facts.status = 'disconnected';
facts.lastMessage = undefined;
facts.retryCount = 0;
facts.maxRetries = 5;
},
derive: {
isConnected: (facts) => facts.status === 'connected',
shouldReconnect: (facts) =>
facts.status === 'error' &&
facts.retryCount < facts.maxRetries &&
facts.url !== '',
},
effects: {
// Manages the WebSocket lifecycle
connection: {
deps: ['url', 'status'],
run: (facts, prev, context) => {
if (facts.url === '' || facts.status !== 'connecting') {
return;
}
const socket = new WebSocket(facts.url);
socket.onopen = () => {
context.system.batch(() => {
context.facts.status = 'connected';
context.facts.retryCount = 0;
});
};
socket.onmessage = (event) => {
context.facts.lastMessage = JSON.parse(event.data);
};
socket.onclose = () => {
context.facts.status = 'error';
};
socket.onerror = () => {
context.facts.status = 'error';
};
// Cleanup: close socket when effect re-runs or system stops
return () => {
socket.close();
};
},
},
},
constraints: {
// Auto-reconnect with backoff
reconnect: {
when: (facts, derive) => derive.shouldReconnect,
require: (facts) => ({
type: 'RECONNECT',
delay: Math.min(1000 * 2 ** facts.retryCount, 30_000),
}),
},
},
resolvers: {
reconnect: {
requirement: 'RECONNECT',
resolve: async (req, context) => {
await new Promise((r) => setTimeout(r, req.delay));
context.system.batch(() => {
context.facts.retryCount = context.facts.retryCount + 1;
context.facts.status = 'connecting';
});
},
},
},
});
// Usage: connect and react to messages
function Chat({ system }) {
const { facts, derived } = useDirective(system);
// Connect on mount
useEffect(() => {
system.batch(() => {
system.facts.url = 'wss://api.example.com/ws';
system.facts.status = 'connecting';
});
}, []);
return (
<div>
<StatusBadge connected={derived.isConnected} />
{facts.lastMessage && (
<Message data={facts.lastMessage} />
)}
</div>
);
}
Step by Step
Effect manages the socket – the
connectioneffect runs whenurlorstatuschanges. It only opens a socket when status is'connecting', and the cleanup return closes it when the effect re-runs or the system stops.system.batch()prevents glitches – whenonopenfires, bothstatusandretryCountupdate atomically. Without batch, constraints would evaluate between the two updates.Constraint triggers reconnect –
shouldReconnectderivation checks if we're in error state and haven't exceeded retries. The constraint emitsRECONNECTwith exponential backoff delay.Resolver adds delay then reconnects – waits the backoff period, increments retry count, and sets status back to
'connecting', which triggers the effect to open a new socket.
Common Variations
Sending messages
// Add a send helper
function sendMessage(system, type: string, payload: unknown) {
system.dispatch({ type: 'WS_SEND', message: { type, payload } });
}
// Add resolver
resolvers: {
send: {
requirement: 'WS_SEND',
resolve: async (req, context) => {
// Access socket through a shared ref or module state
if (context.facts.status !== 'connected') {
throw new Error('Not connected');
}
// Socket reference managed by the effect
},
},
},
Message routing to other modules
// In a chat module, react to WebSocket messages
const chat = createModule('chat', {
constraints: {
handleMessage: {
crossModuleDeps: ['ws.lastMessage'],
when: (facts, derive, cross) =>
cross.ws.lastMessage?.type === 'CHAT_MESSAGE',
require: (facts, derive, cross) => ({
type: 'PROCESS_CHAT',
message: cross.ws.lastMessage,
}),
},
},
});
Related
- Effects – cleanup functions and dependency tracking
- Batch Mutations – atomic multi-field updates
- Multi-Module – cross-module dependencies
- Error Handling – retry and circuit breaker patterns

