npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@accreation/rtkx

v1.0.6

Published

Extensions for Redux Toolkit Query — WebSocket support and chained queries

Readme

RTKX

A modular extension layer for Redux Toolkit Query - first-class WebSocket support, sequential chained queries, and opt-in notifications. Everything ships from a single import.

TypeScript RTK Query React License


Features

| | Feature | Description | |---|---|---| | 🔌 | createWsEndpoint | Pure WebSocket RTK Query endpoint — supports providesTags, initialData, and WS lifecycle notifications | | 🌊 | createStreamingEndpoint | HTTP seed + WebSocket updates in one endpoint — supports providesTags and WS lifecycle notifications | | 🔧 | WebSocketManager | Low-level WS class with exponential back-off reconnect | | 📨 | useWebSocketSend | Send messages over an already-open managed WS connection; optionally invalidates RTK Query tags after each send | | 🔖 | createWsEndpointRef | Create a ref that links an endpoint to useWebSocketSend | | 🔗 | createQueryChain | Fluent builder for sequential chains of RTK Query query endpoints | | ⚛️ | useQueryChain | React hook that runs a query chain and tracks per-step progress | | 🔀 | createMutationChain | Fluent builder for sequential chains of RTK Query mutation endpoints | | 🚀 | useMutationChain | React hook that executes a mutation chain imperatively | | 🔔 | createNotificationMiddleware | Library-agnostic opt-in toast/notification middleware | | 🔀 | useParallelQueries | Fire one endpoint against N args in parallel, results keyed by position | | 🔀 | useQueries | Fire N different endpoints in parallel, results keyed by name |


Table of contents


Installation

Install the package and its peer dependencies:

npm install @accreation/rtkx
# peer deps
npm install @reduxjs/toolkit react react-redux

Peer dependency requirements enforced at install time:

| Package | Required version | |---|---| | @reduxjs/toolkit | ^2.3.0 | | react | ^17.0.0 \|\| ^18.0.0 | | react-redux | ^9.0.0 |


Single import source

rtkx re-exports everything from @reduxjs/toolkit/query/react, @reduxjs/toolkit, and react-redux so your project never needs to import from those packages directly. This eliminates version mismatches - npm resolves a single copy by following rtkx's peer dependency constraints.

// Everything from one place
import {
  // RTK Query
  createApi, fetchBaseQuery, skipToken,
  // Redux
  configureStore,
  // React-Redux
  Provider, useSelector, useDispatch,
  // rtkx extensions
  createWsEndpoint, createStreamingEndpoint,
  useWebSocketSend, createWsEndpointRef,
  createQueryChain, useQueryChain,
  createMutationChain, useMutationChain,
  createNotificationMiddleware,
  useParallelQueries, useQueries,
  getBaseQueryWithAuthorization, configureBaseQueryAuth,
} from '@accreation/rtkx';

Nothing is bundled - these are pure pass-throughs. @reduxjs/toolkit and react-redux remain external (peer deps) and are never duplicated in your bundle.


WebSocket endpoints

createWsEndpoint - pure WebSocket

Use when all data arrives over a socket with no initial HTTP request.

// store/api/chat.ts
import { createApi, fetchBaseQuery, createWsEndpoint } from 'rtkx';

interface ChatMessage {
  id: string;
  author: string;
  text: string;
  timestamp: number;
}

export const chatApi = createApi({
  reducerPath: 'chatApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({

    chatMessages: createWsEndpoint<
      ChatMessage[],  // TCache   - what's stored in the RTK Query cache
      ChatMessage,    // TMessage - what each WS frame delivers
      string          // TArg    - hook argument (room name)
    >(builder, {
      url: 'wss://example.com/chat',

      // Optional: seed the cache before the first message arrives
      initialData: [],

      // Transform raw MessageEvent into TMessage.
      // Return null to silently skip a frame (useful for filtering by event name).
      transformMessage: (event) => JSON.parse(event.data),

      // Mutate the Immer draft to update the cache
      onMessage: (msg, { updateCachedData }) => {
        updateCachedData((draft) => {
          draft.push(msg);
          if (draft.length > 100) draft.shift(); // keep rolling window
        });
      },

      reconnect: { maxAttempts: 10, delay: 500 },

      // Tie this endpoint into createNotificationMiddleware so WS lifecycle
      // events (error / connect / disconnect) fire through the same handler.
      // Use the same string you use as the object key in createApi.
      endpointName: 'chatMessages',

      // Tag the cache entry so other mutations can invalidate it.
      providesTags: (result, _err, room) => [{ type: 'ChatRoom', id: room }],
    }),

  }),
});

export const { useChatMessagesQuery } = chatApi;
function ChatRoom({ room }: { room: string }) {
  const { data: messages = [] } = useChatMessagesQuery(room);
  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}><strong>{m.author}</strong>: {m.text}</li>
      ))}
    </ul>
  );
}

Filtering frames by event name

When a single socket carries multiple event types (e.g. a { event, data } envelope), return null from transformMessage to skip non-matching frames:

function parseEvent<T>(eventName: string) {
  return (raw: MessageEvent): T | null => {
    try {
      const msg = JSON.parse(raw.data) as { event: string; data: T };
      return msg.event === eventName ? msg.data : null; // null = skip
    } catch { return null; }
  };
}

// Each endpoint only processes its own event type
chatMessages: createWsEndpoint(builder, { url: 'ws://...', transformMessage: parseEvent('chat-message') }),
userPresence: createWsEndpoint(builder, { url: 'ws://...', transformMessage: parseEvent('presence') }),

createStreamingEndpoint - HTTP + WebSocket

Use when the initial data comes from REST and subsequent updates stream in over a socket.

interface Todo { id: string; title: string; completed: boolean; }

todoList: createStreamingEndpoint<
  Todo[],   // TCache   - stored in cache (shape after HTTP + WS updates)
  Todo[],   // TMessage - shape of each WS frame
  void      // TArg
>(builder, {
  // HTTP: seed cache on mount
  query: () => '/todos',

  // WebSocket: stream live updates
  url: 'wss://example.com/todos/live',

  // Replace cache with the latest snapshot on each push
  onMessage: (updated, { updateCachedData }) => {
    updateCachedData((draft) => {
      draft.splice(0, draft.length, ...updated);
    });
  },

  reconnect: true, // enable with defaults

  // Middleware integration & tag support
  endpointName: 'todoList',
  providesTags: ['Todo'],
}),

WebSocketManager - standalone

For cases where you need a WebSocket connection outside of RTK Query (e.g. presence tracking, custom protocols):

import { WebSocketManager } from 'rtkx';

const ws = new WebSocketManager<{ type: string; payload: unknown }>(
  'wss://example.com/notifications',
  { reconnect: { maxAttempts: 5 } },
);

const unsubscribe = ws.subscribe((msg) => console.log(msg));

ws.connect();
ws.send({ type: 'subscribe', payload: { channel: 'team-updates' } });

// Clean up
unsubscribe();
ws.disconnect();

Note: createWsEndpoint and createStreamingEndpoint use WebSocketManager internally - you only need this for lower-level scenarios.


useWebSocketSend - sending messages

useWebSocketSend returns a stable send callback that writes to the already-open WebSocket connection that rtkx manages for a given endpoint + arg combination. This is the recommended way to send messages bidirectionally without managing the socket yourself.

Setup - three steps:

1. Create a ref once, outside createApi:

// store/api/chat.ts
import { createApi, createWsEndpoint, createWsEndpointRef } from 'rtkx';

export const chatMessagesRef = createWsEndpointRef<string>(); // TArg = room name

2. Pass the ref to the endpoint's options.ref:

export const chatApi = createApi({
  reducerPath: 'chatApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    chatMessages: createWsEndpoint<ChatMessage[], ChatMessage, string>(builder, {
      ref: chatMessagesRef,          // ← link the endpoint to the ref
      url: 'wss://example.com/chat',
      initialData: [],
      transformMessage: parseEvent<ChatMessage>('chat-message'),
      onMessage: (msg, { updateCachedData }) => {
        updateCachedData((draft) => { draft.push(msg); });
      },
    }),
  }),
});

3. Use the ref in any component to send messages:

import { useWebSocketSend } from 'rtkx';
import { chatMessagesRef, useChatMessagesQuery } from '../store/api/chat';

interface OutgoingMessage {
  event: string;
  data: { author: string; text: string; room: string };
}

function ChatInput({ room, author }: { room: string; author: string }) {
  // The query must be subscribed before send() is called - subscribe here
  // or in a parent component.
  useChatMessagesQuery(room);

  const send = useWebSocketSend<OutgoingMessage, string>(chatMessagesRef, room);

  const [text, setText] = useState('');

  const handleSubmit = () => {
    send({ event: 'chat-message', data: { author, text, room } });
    setText('');
  };

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleSubmit}>Send</button>
    </div>
  );
}

How it works:

  • createWsEndpointRef<TArg>() returns an opaque handle that starts unpopulated.
  • When createApi evaluates your endpoints callback, createWsEndpoint populates the ref with an internal endpoint ID synchronously.
  • rtkx maintains an internal registry mapping endpointId + arg to the live WebSocketManager instance. The manager is registered when the first subscriber mounts and unregistered when the last one unmounts.
  • useWebSocketSend looks up the manager from that registry and returns a useCallback-stable send function.

Requirement: The matching query (useChatMessagesQuery(room)) must be subscribed (mounted somewhere in the tree) before send() is called - rtkx opens the socket when the query subscribes and closes it when the last subscriber unmounts. If send() is called with no active subscription a warning is logged and the message is dropped.

Serialisation: Objects are JSON-serialised automatically. Pass a plain string to send a raw text frame.


WebSocket tags — providesTags / invalidatesTags

Both createWsEndpoint and createStreamingEndpoint accept a providesTags option, identical to a regular builder.query() definition.

chatMessages: createWsEndpoint<ChatMessage[], ChatMessage, string>(builder, {
  url: 'wss://example.com/chat',
  onMessage: (msg, { updateCachedData }) => {
    updateCachedData((draft) => { draft.push(msg); });
  },
  providesTags: (result, _err, room) => [{ type: 'ChatRoom', id: room }],
}),

useWebSocketSend accepts an optional third argument with api and invalidatesTags. When provided, the tags are invalidated immediately after each successful send() call:

import { useWebSocketSend } from 'rtkx';
import { chatMessagesRef, chatApi } from '../store/api/chat';

function ChatInput({ room }: { room: string }) {
  const send = useWebSocketSend<OutgoingMessage, string>(chatMessagesRef, room, {
    api: chatApi,
    invalidatesTags: [{ type: 'MessageCount', id: room }],
  });

  // Calling send() will automatically dispatch:
  // chatApi.util.invalidateTags([{ type: 'MessageCount', id: room }])
  return <button onClick={() => send({ event: 'ping' })}>Ping</button>;
}

createWsEndpointRef works with createStreamingEndpoint too:

export const livePostsRef = createWsEndpointRef<void>();

livePosts: createStreamingEndpoint<Post[], Post, void>(builder, {
  ref: livePostsRef,
  query: () => '/posts',
  url: 'wss://example.com/posts/live',
  onMessage: (post, { updateCachedData }) => {
    updateCachedData((draft) => { draft.push(post); });
  },
}),

Chained queries

When one endpoint's response contains the argument for the next request, createQueryChain + useQueryChain handle the sequencing cleanly - no nested useEffect, no manual loading gates.

Results are keyed by the string name you give each step, so you get data.getAuthor instead of a positional index.

Key uniqueness: every step key within a single chain must be unique. Duplicate keys will silently overwrite the earlier step's result in the data object.

createQueryChain

Define the chain once, outside your component:

// store/chains.ts
import { createQueryChain } from 'rtkx';
import { api } from './api';

// Imagine these endpoints exist in your api:
//   getAuthor(authorId: string)         -> Author      { id, publicationId, name }
//   getPublication(pubId: string)       -> Publication { id, name }
//   getSubscribers(pubId: string)       -> Subscriber[]

export const publicationSubscribersChain = createQueryChain('getAuthor', api.endpoints.getAuthor)
  .next('getPublication', api.endpoints.getPublication,
    (author) => author.publicationId,    // step 2: derive arg from step 1 result
  )
  .next('getSubscribers', api.endpoints.getSubscribers,
    (pub) => pub.id,                     // step 3: derive arg from step 2 result
  )
  .build();

Each .next() call's deriveArg receives two arguments:

  • prev - the immediately previous step's result
  • all - a record of every completed step's result keyed by name

This lets any step reach back to any earlier result:

export const chain = createQueryChain('getAuthor', api.endpoints.getAuthor)
  .next('getPublication', api.endpoints.getPublication, (author) => author.publicationId)
  .next('getAnalytics', api.endpoints.getAnalytics,
    //               prev = Publication     all.getAuthor = Author from step 1
    (pub, all) => ({ pubId: pub.id, authorId: all.getAuthor.id }),
  )
  .build();

useQueryChain

// components/PublicationSubscribers.tsx
import { useQueryChain } from 'rtkx';
import { publicationSubscribersChain } from '../store/chains';

function PublicationSubscribers({ authorId }: { authorId: string }) {
  const {
    data,         // { getAuthor: Author, getPublication: Publication, getSubscribers: Subscriber[] } | undefined
    finalData,    // Subscriber[] - last step's result (shorthand)
    isLoading,
    isError,
    error,
    currentStep,  // 0-based index of the step currently running
    totalSteps,   // 3
    reset,        // re-run chain from scratch
  } = useQueryChain(publicationSubscribersChain, authorId);

  if (isLoading) return <p>Step {currentStep + 1} / {totalSteps}...</p>;
  if (isError)   return <p>Failed: {String(error)}</p>;

  const { getAuthor, getPublication, getSubscribers } = data!;

  return (
    <section>
      <h2>{getAuthor.name} - {getPublication.name}</h2>
      <ul>{getSubscribers.map((s) => <li key={s.id}>{s.email}</li>)}</ul>
      <button onClick={reset}>Refresh</button>
    </section>
  );
}

Behaviours:

  • Re-runs automatically when authorId changes (shallow equality check)
  • Pass { skip: true } as the third argument to pause execution
  • Each step dispatches via RTK Query - results are cached and deduped normally

createMutationChain

Like createQueryChain but for mutations - executed imperatively via a trigger function returned by useMutationChain. Useful for multi-step write flows where each step's output feeds the next.

Key uniqueness: step keys must be unique within each chain definition.

// store/chains.ts
import { createMutationChain } from 'rtkx';
import { postsApi } from './api/posts';

export const publishPostChain = createMutationChain(
  'createPost',
  postsApi.endpoints.createPost,
)
  .next('uploadAssets', postsApi.endpoints.uploadAssets,
    // prev = CreatePostResult (step 1 output)
    (prev) => ({ postId: prev.postId, files: prev.pendingFiles }),
  )
  .next('publish', postsApi.endpoints.publishPost,
    (prev) => ({ postId: prev.postId }),
  )
  .build();

The same all cross-step access is available just as in createQueryChain:

.next('notifyFollowers', postsApi.endpoints.notifyFollowers,
  (prev, all) => ({ postId: prev.postId, authorId: all.createPost.authorId }),
)

useMutationChain

// components/NewPost.tsx
import { useMutationChain } from 'rtkx';
import { publishPostChain } from '../store/chains';

function NewPost() {
  const [publish, {
    isLoading,
    isSuccess,
    isError,
    error,
    finalData,    // PublishPostResult - last step's result
    data,         // { createPost: CreatePostResult, uploadAssets: UploadResult, publish: PublishPostResult }
    currentStep,  // 0-based index of the step currently executing
    totalSteps,   // 3
    reset,        // reset state back to idle
  }] = useMutationChain(publishPostChain);

  const handleClick = () => {
    publish({ title: 'Hello World', body: '...', tags: ['intro'] });
  };

  if (isLoading) return <p>Step {currentStep + 1} / {totalSteps}…</p>;
  if (isError)   return <p>Failed at step {currentStep + 1}: {String(error)}</p>;
  if (isSuccess) return <p>✓ Published: {finalData?.url}</p>;

  return <button onClick={handleClick}>Publish post</button>;
}

Behaviours:

  • execute(arg) returns a Promise that resolves with the last step's result
  • Calling execute() again while a chain is running cancels the in-flight chain first
  • Steps are executed sequentially with dispatch(endpoint.initiate(...)) - each step's result feeds the next via deriveArg
  • reset() clears all state back to idle without re-running

Parallel queries

Both hooks are zero-config - no chain definition file, no builder. Just pass the endpoint(s) and args directly in the component.

useParallelQueries

Fires one endpoint against an array of args simultaneously and returns a single aggregated state. Perfect for loading a batch of items by ID when no server-side bulk endpoint exists.

Subscriptions are managed incrementally: adding an ID subscribes a new query; removing an ID tears down that subscription immediately.

import { useParallelQueries } from 'rtkx';
import { postsApi } from './store/api';

function PostBatch({ postIds }: { postIds: string[] }) {
  const {
    data,       // (Post | undefined)[] - same order as postIds
    isLoading,  // true while ANY query is still in its initial load
    isFetching, // true while ANY query is re-fetching
    isSuccess,  // true only when EVERY query has succeeded
    isError,    // true when at least one query has errored
    errors,     // (unknown | undefined)[] - per ID, same order as postIds
    refetchAll, // re-fetches every active query immediately
  } = useParallelQueries(
    postsApi.endpoints.getPost,  // single endpoint
    postIds,                      // string[] - one request per element
    // optional shared options:
    // { skip, pollingInterval, refetchOnMountOrArgChange, concurrent }
  );

  if (isLoading) return <p>Loading {postIds.length} posts…</p>;
  if (isError)   return <p>Some requests failed</p>;
  // isSuccess gates the whole view - data is complete when true
  return (
    <ul>
      {data.map((post, i) =>
        post ? <li key={postIds[i]}>{post.title}</li> : null,
      )}
    </ul>
  );
}

Behaviours:

  • data entries are undefined while their query is still loading.
  • isSuccess is true only when every query has resolved - use it to gate a combined view that should appear all at once.
  • An empty postIds array short-circuits with isSuccess: true immediately.
  • Dynamically responds if the array changes length between renders.

Concurrency limiting (concurrent):

By default all requests fire simultaneously. Pass concurrent to cap how many can be in-flight at once. As each request settles (succeeds or errors), the next queued request starts automatically - sliding-window semantics.

// Fire at most 10 requests at a time; the remaining 90 queue up and
// start as earlier requests complete.
const { data, isSuccess } = useParallelQueries(
  postsApi.endpoints.getPost,
  hundredPostIds,
  { concurrent: 10 },
);

useQueries

Fires N different endpoints in parallel. Each entry is labelled with a string key; results are returned as a named object so you destructure by key instead of accessing by index.

The key type-propagates through a mapped conditional type (DataRecord<T>), so each field of data carries the exact TypeScript type for its endpoint - no casting required.

import { useQueries } from 'rtkx';
import { statsApi, postsApi, commentsApi } from './store/api';

function Dashboard() {
  const {
    data,       // named record - each key typed to its endpoint's result
    isLoading,
    isSuccess,
    isError,
    errors,     // Partial<{ stats: unknown; posts: unknown; comments: unknown }>
    refetchAll,
  } = useQueries([
    ['stats',    statsApi.endpoints.getSiteStats,    undefined  ],
    ['posts',    postsApi.endpoints.getRecentPosts,  undefined  ],
    ['comments', commentsApi.endpoints.getComments,  'latest'   ],
  ] as const);

  // Destructure by name - fully typed, no data[0] / data[1]
  const { stats, posts, comments } = data;
  //       ^ SiteStats | undefined
  //               ^ Post[] | undefined
  //                        ^ Comment[] | undefined

  if (isLoading) return <p>Loading…</p>;
  // isSuccess gates the entire view
  if (!isSuccess) return null;

  return (
    <section>
      <p>Posts published: {stats!.totalPosts} | Comments today: {stats!.commentsToday}</p>
      <ul>{posts!.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      <ul>{comments!.map((c) => <li key={c.id}>{c.author}: {c.text}</li>)}</ul>
    </section>
  );
}

Per-entry options are passed as a fourth tuple element:

useQueries([
  ['author',  api.endpoints.getAuthor,  authorId, { skip: !authorId }               ],
  ['posts',   api.endpoints.getPosts,   authorId, { pollingInterval: 30_000 }        ],
  ['drafts',  api.endpoints.getDrafts,  'mine',   { refetchOnMountOrArgChange: true } ],
] as const);

Behaviours:

  • isLoading - any non-skipped query is in its initial load.
  • isSuccess - all non-skipped queries have succeeded; skipped entries are treated as already settled.
  • errors is a partial named record matching the input keys.
  • Subscriptions are established on mount and torn down on unmount.

| Entry position | Content | |---|---| | [0] | Unique string key - becomes the property name on data and errors | | [1] | RTK Query endpoint (api.endpoints.<name>) | | [2] | Arg passed to the endpoint (use undefined for void endpoints) | | [3] (optional) | { skip?, pollingInterval?, refetchOnMountOrArgChange? } |


Notifications (opt-in)

createNotificationMiddleware is completely independent - add it to configureStore only if you want it. It fires a typed callback on every RTK Query success or failure. No UI library is included or required - you plug in whatever toast system you use.

createNotificationMiddleware

// store/store.ts
import { configureStore, createNotificationMiddleware } from 'rtkx';
import { toast } from 'react-toastify'; // or notistack, sonner, anything
import { api } from './api';

const notifMiddleware = createNotificationMiddleware({
  // Your toast library - or any callback you like
  handler: (n) => {
    // n is a fully-typed RtkxNotification:
    // { type, message, endpointName, operationType, error?, isNetworkError, isAuthError }
    toast(n.message, { type: n.type });                    // react-toastify
    // enqueueSnackbar(n.message, { variant: n.type });    // notistack
    // sonnerToast[n.type](n.message);                     // sonner
  },

  defaults: {
    onError:   true,             // show errors for ALL endpoints (default: true)
    onSuccess: 'mutations-only', // 'mutations-only' | true | false  (default)
  },
});

export const store = configureStore({
  middleware: (getDefault) =>
    getDefault().concat(api.middleware, notifMiddleware.middleware),
});

How mutation detection works: rtkx checks action.meta.arg.type === 'mutation' - RTK Query's own flag, not name heuristics.

Network & auth errors always fire (FETCH_ERROR and HTTP 401), bypassing all per-endpoint config.

Per-endpoint control

const notifMiddleware = createNotificationMiddleware({
  handler: (n) => toast(n.message, { type: n.type }),

  endpoints: {
    // Suppress errors from a noisy polling endpoint
    syncComments: { onError: false },

    // Custom success message for a specific mutation
    createPost:   { onSuccess: 'Post published!' },

    // Force-show success even if global onSuccess is false
    deleteAccount: { onSuccess: true },

    // Suppress WS connection errors for a chatty socket
    getTimeline:  { onError: false },
  },
});

| Value for onError / onSuccess | Behaviour | |---|---| | true | Always show | | false | Always suppress | | 'some string' | Always show with this custom message | | (omitted) | Follows defaults |


WebSocket lifecycle notifications

When you provide endpointName on a createWsEndpoint or createStreamingEndpoint, the endpoint dispatches Redux actions (wsConnectedAction, wsDisconnectedAction, wsErrorAction) on lifecycle events. createNotificationMiddleware intercepts these actions automatically — no extra wiring needed.

// 1. Add endpointName to the endpoint definition
endpoints: (builder) => ({
  getTimeline: createStreamingEndpoint(builder, {
    endpointName: 'getTimeline',   // must match the object key
    query: () => '/timeline',
    url: 'wss://example.com/timeline/live',
    onMessage: (update, { updateCachedData }) => {
      updateCachedData((draft) => { draft.push(update); });
    },
  }),
}),

// 2. Configure notifications in one place — same config for HTTP and WS
const notifMiddleware = createNotificationMiddleware({
  handler: (n) => toast(n.message, { type: n.type }),

  // Per-endpoint override — applies to BOTH HTTP errors and WS errors
  endpoints: {
    getTimeline: { onError: false }, // silence WS connection errors
  },

  // Global WS lifecycle defaults
  ws: {
    onError:      true,   // default: true  — show WS errors globally
    onConnect:    false,  // default: false
    onDisconnect: 'Connection lost — reconnecting…', // custom message
  },
});

WS event → notification mapping:

| Event | Default | Respects per-endpoint onError? | |---|---|---| | WS error | ws.onError (default true) | ✅ yes | | WS connected | ws.onConnect (default false) | — | | WS disconnected | ws.onDisconnect (default false) | — |

The action creators are also exported from the package if you need to dispatch them manually or write custom middleware:

import { wsConnectedAction, wsDisconnectedAction, wsErrorAction } from 'rtkx';

Custom messages

Override the default message generators for full control:

const notifMiddleware = createNotificationMiddleware({
  handler: (n) => toast(n.message, { type: n.type }),

  messages: {
    // Return empty string to fall back to the built-in default
    error:     (endpointName, operationType, error) => {
                 const msg = (error as any)?.data?.message;
                 return msg ? `${operationType} failed: ${msg}` : '';
               },
    success:   (endpointName, operationType) => `${operationType} successful`,
    network:   'Check your internet connection',
    authError: 'Your session has expired - please log in again',
    unknown:   'Something went wrong',
  },
});

RtkxNotification type

interface RtkxNotification {
  type:           'success' | 'error' | 'warning' | 'info';
  message:        string;
  endpointName:   string;
  operationType:  'create' | 'update' | 'delete' | 'fetch' | 'other';
  error?:         unknown;   // raw RTK Query error payload
  isNetworkError: boolean;
  isAuthError:    boolean;
}

Reconnect options

Applies to createWsEndpoint, createStreamingEndpoint, and WebSocketManager.

| Option | Type | Default | Description | |---|---|---|---| | enabled | boolean | true | Enable auto-reconnect | | maxAttempts | number | 5 | Give up after this many attempts | | delay | number | 1000 | Initial delay in ms | | maxDelay | number | 30000 | Delay is capped at this value | | factor | number | 2 | Multiply delay by this on each attempt (exponential back-off) |

reconnect: { maxAttempts: 10, delay: 500, maxDelay: 15_000, factor: 1.5 }
reconnect: true   // enable with all defaults
reconnect: false  // disable entirely

TypeScript generics reference

| Function | Generics | Notes | |---|---|---| | createWsEndpoint<TCache, TMessage, TArg> | TCache - cache shape; TMessage - per-frame type; TArg - hook arg | transformMessage may return TMessage \| null; providesTags mirrors builder.query() | | createStreamingEndpoint<TCache, TMessage, TArg> | same as above | query provides the HTTP seed; providesTags mirrors builder.query() | | createWsEndpointRef<TArg>() | TArg - query-arg type | Returns a WsEndpointRef<TArg>; pass to options.ref and useWebSocketSend | | useWebSocketSend<TMessage, TArg>(ref, arg, opts?) | TMessage - outgoing message type; TArg - inferred from ref | opts.invalidatesTags + opts.api dispatch tag invalidation after each send | | createQueryChain(key, endpoint) | key - unique step name; endpoint - first RTK Query endpoint | Returns a QueryChainBuilder; add steps with .next(key, endpoint, deriveArg) | | .next(key, endpoint, deriveArg) | all inferred from the endpoint | key must be unique within the chain; deriveArg receives prev result + full all record | | createMutationChain(key, endpoint) | same as createQueryChain | Same fluent API; executed imperatively via useMutationChain's trigger | | useMutationChain(chain) | inferred from chain | Returns [execute, state] tuple | | useParallelQueries(endpoint, args, opts?) | inferred from endpoint | data is (TResult \| undefined)[] in arg order; errors is (unknown \| undefined)[]; opts.concurrent limits in-flight requests | | useQueries(entries) | inferred from the as const tuple | data is DataRecord<T> - each key typed to its endpoint's result; errors is a matching partial record |


License

MIT