@ivancerovina/contracts-react
v1.0.0
Published
React hooks for @ivancerovina/contracts WebSocket namespaces — typed Socket.IO via Jotai atom families
Downloads
10
Maintainers
Readme
@ivancerovina/contracts-react
React hooks for @ivancerovina/contracts WebSocket namespaces. Typed Socket.IO connections managed by Jotai atom families — connections open when the first component subscribes and close when the last one unmounts.
Install
pnpm add @ivancerovina/contracts-react @ivancerovina/contracts jotai socket.io-client reactreact and jotai must be wrapped in a Provider at the app root.
Setup
Call createContractSocket once with your server URL. This returns the hooks bound to that URL.
import { createContractSocket } from "@ivancerovina/contracts-react";
export const { useContractSocket, useContractSocketEvent } =
createContractSocket("http://localhost:3000");Defining events in contracts
Events are defined in the ws array of a contract. Each event is either a bare Zod schema (no ack) or { data, ack } for acknowledgement support.
import { createContract } from "@ivancerovina/contracts";
import { z } from "zod";
const taskSchema = z.object({
id: z.string().uuid(),
title: z.string(),
status: z.enum(["todo", "in_progress", "done"]),
});
export const TaskContract = createContract({
name: "Tasks",
description: "Task management",
baseRoute: "/tasks",
errors: {},
routes: { /* ... */ },
ws: [
{
namespace: "/tasks",
serverEvents: {
// Bare schema — no ack
taskCreated: taskSchema,
// Ack without args
taskUpdated: { data: taskSchema, ack: z.undefined() },
// Ack with typed response
requestSync: {
data: z.object({ since: z.string().datetime() }),
ack: z.object({ synced: z.boolean(), count: z.number() }),
},
},
clientEvents: {
subscribeToProject: z.object({ projectId: z.string().uuid() }),
unsubscribeFromProject: z.object({ projectId: z.string().uuid() }),
},
},
],
});useContractSocket
Low-level hook. Returns a typed socket handle with on, emit, connected, and the raw socket.
function TaskList() {
const tasks = useContractSocket(TaskContract, "/tasks");
// ^ autocompletes to contract namespace strings
useEffect(() => {
const unsub = tasks.on("taskCreated", (event) => {
// ^ autocompletes to server event names
console.log(event.data.title);
// ^ typed: { id: string; title: string; status: ... }
});
return unsub;
}, [tasks.on]);
// Emit client events — payload is typed
tasks.emit("subscribeToProject", { projectId: "abc-123" });
// Connection state
if (tasks.connected) { /* ... */ }
return null;
}on() handler argument
The handler receives an object, not raw data:
| Event definition | Handler argument |
|-----------------|-----------------|
| taskCreated: taskSchema | { data: Task } |
| taskUpdated: { data: taskSchema, ack: z.undefined() } | { data: Task; ack: () => void } |
| requestSync: { data: ..., ack: responseSchema } | { data: ...; ack: (response: Response) => void } |
ack only exists on the object when the event definition includes it.
useContractSocketEvent
Subscribes to a single server event with automatic lifecycle management and Zod validation. No useEffect boilerplate needed.
function TaskFeed() {
// No ack — event has { data }
useContractSocketEvent(TaskContract, "/tasks", "taskCreated", (event) => {
console.log(event.data.title);
// ^ Zod-validated and typed
});
return <div>...</div>;
}Ack support
// Ack without params
useContractSocketEvent(TaskContract, "/tasks", "taskUpdated", (event) => {
console.log(event.data.title);
event.ack(); // no args
});
// Ack with typed params
useContractSocketEvent(TaskContract, "/tasks", "requestSync", (event) => {
console.log(event.data.since);
event.ack({ synced: true, count: 42 });
// ^ typed: { synced: boolean; count: number }
});Emitting from the handler
The handler receives event.socket — the full typed socket for the namespace. Use it to emit client events without needing a separate useContractSocket call.
useContractSocketEvent(TaskContract, "/tasks", "taskCreated", (event) => {
console.log(event.data.title);
// Emit from inside the handler
event.socket.emit("subscribeToProject", { projectId: "abc-123" });
// ^ autocompletes to client event names
// Full socket access
event.socket.connected;
event.socket.on("taskUpdated", (e) => { /* ... */ });
});Connection lifecycle
Connections are managed by a Jotai atomFamily keyed by namespace:
- First component subscribing to a namespace opens the WebSocket connection
- Multiple components using the same namespace share one connection
- Last component unmounting disconnects the socket and removes it from the cache
No manual connect/disconnect needed.
Exported types
| Type | Description |
|------|-------------|
| WsNamespaces<C> | Union of namespace strings from a contract |
| FindNamespace<C, N> | Extract a specific NamespaceDefinition by namespace string |
| ServerEvents<NS> | Union of server event names from a namespace |
| EventHandlerArg<E> | The typed { data, ack? } object for an event |
| TypedSocket<NS> | The socket handle with typed on, emit, connected, socket |
Scripts
pnpm build # Build with tsdown
pnpm dev # Watch mode
pnpm lint # Biome check