redux-requests-factory
v2.4.0
Published
Typed Redux request lifecycle, deduplicated cache, and SSR coordination without React coupling
Maintainers
Readme
Redux Requests Factory
Build independent Redux request modules that share server data without duplicate requests or hand-written lifecycle state—and give every SSR render one place to await or cancel all of its work.
redux-requests-factory is for teams building Redux applications where the
same data is used across multiple components and features. It is especially
useful in medium and large codebases developed by several teams in parallel:
developers share typed request actions and selectors, while the library owns
the request lifecycle, caching, load deduplication, and action-stream noise.
The same request modules work in client-rendered SPAs and SSR applications. A server render can dispatch and await requests, then hydrate or merge the cached request state into the browser store so client components reuse the same selectors and loaded data. Every SSR store owns its request registry: actions from any factory or serialized parameter key automatically join that store's batch and can be awaited or canceled together.
It is useful when you want the same request lifecycle everywhere:
none -> loading -> successnone -> loading -> failed- canceled-result suppression
- request-level and global loading selectors
- request caching, optionally separated by serialized parameters
- client-side SPA and SSR request flows
- request-scoped SSR batch awaiting and cancellation
- optional debounce
- optional follow-up actions after success or failure
Contents
- Core strengths
- What the factory optimizes
- Installation
- Breaking changes: migrating from v1 to v2
- Store Setup
- Quick Start
- React Suspense with
use(promise) - Requests With Parameters
- Updating Cached Data
- Global Loading
- API Reference
- Custom Factory Instances
- SSR
- Examples
- TypeScript
- License
Core strengths
redux-requests-factory turns each remote operation into a reusable, typed
Redux module with its own actions, selectors, lifecycle, and cache identity.
- Independent consumers. Components and features can request the same data without coordinating mount order. Concurrent normal loads share one in-flight Promise, and successful responses are reused from Redux.
- Redux-native orchestration. Request modules compose with reducers, sagas, epics, middleware, analytics, and shared Reselect selectors through regular Redux actions and state.
- One request model across Redux, Suspense, and SSR. The same load action works in effects, event handlers, and server preloading. Responses, errors, and request status remain available through Redux selectors, without introducing a separate query client or a second data cache.
- Streaming SSR and selective hydration. A normal dispatch returns the
stable latest Promise required by React's
use()API, retaining its identity both while pending and after settlement until request policy starts new work.requestVersionSelectorlets a component observe that replacement even for aLoading -> Loadingforced request. IndependentSuspenseboundaries can therefore stream as their requests resolve, while the transferred Redux state lets client hydration reuse those results instead of repeating the server requests. - Policy-driven recovery after SSR. Regular terminal-state retries and hydrated terminal-state retries are configured separately. A server render can keep failed or canceled work terminal, while a new browser middleware retries that hydrated state exactly once without detecting server or browser globals and without creating a client retry loop.
- Bounded automatic retries. Each request module can retry transient failures within the same dispatch Promise, using a typed error predicate and a fixed or computed backoff. Intermediate failures stay inside the active lifecycle, final side effects run only once, and cancellation interrupts both transport work and a pending retry delay.
- UI and transport independence. The request function can use
fetch, an SDK, a database client, or any Promise-returning implementation. The same module can serve React components, server code, and other consumers. - One lifecycle boundary per SSR render. Each middleware instance owns an isolated registry of active executions across every request factory and serialized parameter key. A server render can await one action, await its complete dynamic batch, or cancel all transport work through the store—without collecting request handles or maintaining a route-level request list.
- Explicit lifecycle control. Normal loads, forced reloads, direct request
execution, cancellation with
AbortSignal, manual state updates, reset, freshness, debounce, global loading, and follow-up actions use one consistent API. - TypeScript contracts. Params, responses, transformed values, errors, actions, selectors, custom Redux state keys, and dispatch Promises remain typed across client and server flows.
What the factory optimizes
Request caching and load deduplication
loadDataAction checks the request state by request key before starting work.
Calling it repeatedly does not produce repeated network requests:
- while a request is loading, every
loadDataActiondispatch returns the same in-flightPromise; - after a successful request, another
loadDataActiondispatch uses the cached Redux response, skips the request while it remains fresh, and returns the same settled Promise; - after a failed or canceled request, retry policy decides whether the same settled Promise is retained or replaced by one new execution;
forcedLoadDataActionremains available when an explicit reload is needed.
const firstLoad = dispatch(loadDataAction({ id: 1 }));
const duplicateLoad = dispatch(loadDataAction({ id: 1 }));
firstLoad === duplicateLoad; // true while pending and after settlementWith serializeRequestParameters, each serialized parameter set gets its own
request status, response, error, and latest Promise. Components can dispatch
the same load action freely without implementing their own cache or duplicate
request guards.
For SSR, loadDataRetryStatuses controls further work in the current store,
while loadDataHydratedRetryStatuses can grant one recovery attempt to
terminal state received by a new browser middleware. This keeps server retries
bounded without preventing the client from recovering from a failed or aborted
server request.
Independent components and parallel team development
This allows components to stay independent. They do not need a shared parent, an application-level loading coordinator, or knowledge of which component requested the data first. When components use the same factory instance and the same serialized request key, the factory starts at most one normal load and makes its response available to every component through the same selector.
In a large application, a shared request module becomes the contract between independently developed components. Different developers or feature teams can use the same load action and selectors without coordinating component mount order or adding feature-specific request guards. The factory coordinates the runtime behavior, so one team's component does not create duplicate requests for data that another team's component is already loading or has already cached.
function UserName({ id }) {
const dispatch = useDispatch();
const user = useSelector((state) => userSelector(state)({ id }));
useEffect(() => {
dispatch(loadUserAction({ id }));
}, [dispatch, id]);
return <strong>{user?.name}</strong>;
}
function UserAvatar({ id }) {
const dispatch = useDispatch();
const user = useSelector((state) => userSelector(state)({ id }));
useEffect(() => {
dispatch(loadUserAction({ id }));
}, [dispatch, id]);
return user ? <img alt={user.name} src={user.avatarUrl} /> : null;
}These components do not coordinate with each other. If they mount together, both dispatch the same load action, but only one request runs. When it succeeds, Redux updates both selector consumers. If either component mounts later, it immediately reads the cached response and its normal load action does not start another request.
Teams only need to agree on the shared request module, its parameter contract, and its serialized request key. Components can then evolve independently while the request state, caching, deduplication, and result distribution remain centralized in the factory.
This removes the need for manual request deduplication and cache coordination
between components. The guarantee applies to normal loadDataAction calls;
forcedLoadDataAction intentionally requests a new execution. When debounce is
enabled, request execution remains subject to its configured timing rules.
Use forcedLoadDataAction when a user action or an application event may have
made the cached response stale. For example, after a user completes a level,
the application can explicitly reload that user's data. In this example,
reloadUserAction is the user's forcedLoadDataAction:
async function handleLevelCompleted(userId) {
await store.dispatch(completeLevelAction({ userId }));
await store.dispatch(reloadUserAction({ userId }));
}completeLevelAction changes the user on the server. reloadUserAction then
bypasses the successful request cache, fetches the updated user, and makes the
fresh response available to every component using the user selector.
The same approach applies to a manual refresh button, a completed mutation, a
WebSocket event, or another freshness trigger. Regular components should keep
using loadDataAction; only the code handling the freshness event needs to know
that the cached data must be replaced.
Shared derived data with Reselect
Factory selectors can be composed with Reselect to create application-specific views of a cached response without changing the response stored in Redux. The factory caches the request and server data; Reselect separately memoizes filtering, mapping, sorting, aggregation, and other derived computations.
Install reselect as a direct application dependency when composing selectors
in application code:
npm install reselectDefine shared selectors once at module scope and export the same selector instances to every component that needs them:
import { createSelector } from 'reselect';
import { usersSelector } from './users-request';
export const activeUsersSelector = createSelector([usersSelector], (users) =>
(users ?? []).filter((user) => user.isActive)
);
export const activeUserCountSelector = createSelector(
[activeUsersSelector],
(users) => users.length
);Different components can consume these selectors independently:
function ActiveUserList() {
const users = useSelector(activeUsersSelector);
return users.map((user) => <UserRow key={user.id} user={user} />);
}
function ActiveUserCount() {
const count = useSelector(activeUserCountSelector);
return <span>{count} active users</span>;
}As long as the factory response reference has not changed, Reselect reuses the memoized derived values. Components and feature teams can therefore share expensive calculations without duplicating either the request or the calculation. Do not create a shared selector inside a component render; doing so creates a new memoization cache for every render instead of sharing one cache between consumers.
A cleaner Redux action stream
In v2, the requests middleware consumes factory command actions internally by
default. Commands such as loadDataAction do not continue to later middleware
or reducers unless forwarding is explicitly enabled:
const { middleware: requestsFactoryMiddleware } =
createRequestsFactoryMiddleware();This keeps factory command actions out of subsequent middleware, reducers, and
the corresponding Redux action stream. Request lifecycle state updates still
reach the built-in reducer. Lazy requestFulfilledAction and
requestRejectedAction subscription actions are dispatched only when their
action creators have been accessed.
Forwarding can be enabled for the individual commands that an epic or another consumer actually needs:
dispatch(
loadDataAction(params, {
forwardFactoryAction: true,
})
);This combination keeps repeated loads from creating repeated requests and keeps unused factory commands and subscription actions from adding noise to the application's action flow.
Installation
npm install redux-requests-factoryredux is a peer dependency:
npm install reduxBreaking changes: migrating from v1 to v2
Version 2 changes the default command-forwarding behavior. In v1, factory commands were forwarded as plain Redux actions to subsequent middleware and reducers. In v2, they are consumed by the requests middleware by default.
| Behavior | v1 default | v2 default |
| ---------------------------------------------------------------------------- | ---------- | ---------- |
| Execute the request command | Yes | Yes |
| Update request state through the built-in reducer | Yes | Yes |
| Return a request Promise<void> from dispatch | Yes | Yes |
| Forward the factory command to later middleware and reducers | Yes | No |
| Dispatch enabled requestFulfilledAction and requestRejectedAction events | Yes | Yes |
If your application only dispatches factory commands and reads request data through factory selectors, no compatibility setting is needed:
const { middleware } = createRequestsFactoryMiddleware();Requests, caching, selectors, global loading, dispatch Promises, and enabled fulfilled/rejected lifecycle events continue to work.
If application reducers, epics, sagas, analytics, logging, or custom middleware must still observe every factory command, restore the v1 behavior globally:
const { middleware } = createRequestsFactoryMiddleware({
forwardFactoryActions: true,
});Performance warning: avoid enabling
forwardFactoryActions: truein application code. It forwards every factory command through all subsequent middleware and reducers. This adds unnecessary work, can degrade application performance, and creates action-stream noise, especially when load actions are dispatched frequently or by multiple components. Use this setting only as a temporary v1 compatibility measure.
The preferred v2 migration is to enable forwarding only for commands that have an external consumer:
dispatch(
loadUsersAction(undefined, {
forwardFactoryAction: true,
})
);Before upgrading, search the application for consumers of command action types,
including loadDataAction.type, forcedLoadDataAction.type,
doRequestAction.type, and cancelRequestAction.type. Typical places are
ofType(...), saga watchers, reducer cases, analytics middleware, and tests
that assert command actions in Redux DevTools or logger output.
Consumers of requestFulfilledAction and requestRejectedAction do not require
forwardFactoryAction: true. Those are separate lifecycle events dispatched by
the factory after their action creators have been accessed.
Store Setup
Add the requests reducer under stateRequestsKey, then apply the middleware.
import { createStore, applyMiddleware, combineReducers } from 'redux';
import {
stateRequestsKey,
requestsReducer,
createRequestsFactoryMiddleware,
} from 'redux-requests-factory';
export const reducer = combineReducers({
[stateRequestsKey]: requestsReducer,
// other reducers
});
const { middleware: requestsFactoryMiddleware } =
createRequestsFactoryMiddleware();
const store = createStore(reducer, applyMiddleware(requestsFactoryMiddleware));
export default store;The default stateRequestsKey is requests.
Redux Toolkit
With Redux Toolkit, prepend the requests middleware so it can handle factory actions before the default thunk middleware:
import { configureStore } from '@reduxjs/toolkit';
import {
createRequestsFactoryMiddleware,
requestsReducer,
stateRequestsKey,
} from 'redux-requests-factory';
export const makeStore = () => {
const { middleware: requestsFactoryMiddleware } =
createRequestsFactoryMiddleware();
return configureStore({
reducer: {
[stateRequestsKey]: requestsReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(requestsFactoryMiddleware),
});
};Factory commands are implementation details by default in v2. To let epics, custom reducers, logging, or another integration observe every command, enable forwarding explicitly. Global forwarding is provided primarily for temporary v1 compatibility and is not recommended for regular application usage:
const { middleware: requestsFactoryMiddleware } =
createRequestsFactoryMiddleware({
forwardFactoryActions: true,
});With the default forwarding disabled, requests and the built-in reducer continue to work. Subsequent middleware and reducers receive internal request-state actions plus any lazy fulfilled or rejected actions that have been enabled.
Prefer overriding the setting only for the dispatch that an external consumer needs:
dispatch(
loadDataAction(params, {
forwardFactoryAction: true,
})
);Likewise, forwardFactoryAction: false suppresses one command when middleware
forwarding is enabled globally.
Quick Start
Create a request module and export only the actions and selectors your app needs.
import { requestsFactory } from 'redux-requests-factory';
const loadUsersRequest = (_params, { signal }) =>
fetch('https://mysite.com/users', { signal }).then((res) => res.json());
export const {
loadDataAction: loadUsersAction,
forcedLoadDataAction: reloadUsersAction,
cancelRequestAction: cancelLoadUsersAction,
responseSelector: usersSelector,
errorSelector: usersErrorSelector,
isLoadingSelector: isLoadingUsersSelector,
isLoadedSelector: isLoadedUsersSelector,
} = requestsFactory({
request: loadUsersRequest,
stateRequestKey: 'users',
});Always forward the request context's signal to fetch, an SDK, or other
abort-aware work. This lets both cancelRequestAction and middleware-wide SSR
cancellation stop the underlying operation instead of only ignoring its late
result.
Use the generated API in a component.
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
loadUsersAction,
reloadUsersAction,
usersSelector,
isLoadingUsersSelector,
} from './requests/users';
const Users = () => {
const dispatch = useDispatch();
const users = useSelector(usersSelector) ?? [];
const isLoading = useSelector(isLoadingUsersSelector);
useEffect(() => {
dispatch(loadUsersAction());
}, [dispatch]);
return (
<div>
<button onClick={() => dispatch(reloadUsersAction())}>Reload</button>
{isLoading ? <span>Loading...</span> : null}
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
};loadDataAction starts work only when the current loading, freshness, terminal
retry, and hydration retry policies require it. forcedLoadDataAction always
starts a new execution.
responseSelector returns the original response, or undefined before the
request succeeds. Apply UI defaults such as ?? [] at the rendering boundary.
Use transformResponse only when the selector itself should expose a
transformed value.
React Suspense with use(promise)
In a client-rendered React 19 application, loadDataAction provides the stable
latest Promise read by React's use() API. The middleware retains the same
Promise both while it is pending and after it settles. Suspense handles the
pending state, while response and error data continue to come from normal Redux
selectors.
The hook dispatches a factory command during render, so that command must not notify later middleware or Redux subscribers. Internal request-state actions still update the reducer. Disable automatic terminal-state retries so a failed render reads the retained settled Promise instead of starting fresh work:
const { middleware: requestsFactoryMiddleware } =
createRequestsFactoryMiddleware({
globalLoadingEnabled: false,
loadDataRetryStatuses: [],
});The render hook can pass the normal load Promise to use() unconditionally:
import { use } from 'react';
import { useAppDispatch, useAppSelector } from './hooks';
import {
loadUsersAction,
usersRequestVersionSelector,
} from './users-request';
function useLoadUsers() {
const dispatch = useAppDispatch();
useAppSelector(usersRequestVersionSelector);
use(dispatch(loadUsersAction()));
}After success, failure, or cancellation, the next normal cached load returns the exact same settled Promise. For preloaded state in a new middleware runtime, the first cached load creates one fulfilled Promise and every subsequent load returns it. A stale response, configured retry, or forced/direct request replaces that entry with a new Promise.
requestVersionSelector returns 0 before the first execution and increments
whenever a real request execution starts. Returning an already cached pending
or settled Promise does not increment it. Subscribing to the selector makes the
component render again and read a replacement pending Promise, including when
a forced request replaces another request that is already Loading. Its value
does not conditionally gate use(). Parameterized factories maintain an
independent version for every serialized request key.
Read the result and error with selectors as usual:
import { Suspense } from 'react';
import { useAppSelector } from './hooks';
import { usersErrorSelector, usersSelector } from './users-request';
function UsersResult() {
useLoadUsers();
const users = useAppSelector(usersSelector) ?? [];
const error = useAppSelector(usersErrorSelector);
if (error) {
return <div role="alert">Could not load users.</div>;
}
return users.map((user) => <div key={user.id}>{user.name}</div>);
}
function Users() {
return (
<Suspense fallback={<div>Loading users...</div>}>
<UsersResult />
</Suspense>
);
}Request failures are caught by the factory and stored in Redux, so this pattern does not require an Error Boundary for normal request errors. The dispatch Promise resolves only after the request lifecycle finishes; it does not resolve to the response value.
For an explicit refresh, dispatch forcedLoadDataAction normally from the user
event rather than passing it directly to use():
import { useAppDispatch } from './hooks';
import { forceLoadUsersAction } from './users-request';
function ForceReloadButton() {
const dispatch = useAppDispatch();
return (
<button onClick={() => dispatch(forceLoadUsersAction())}>
Force reload
</button>
);
}The forced action starts a new execution and increments its request version.
That update renders the subscribed component even if its status was already
Loading. The render calls loadUsersAction, which reuses the exact in-flight
Promise started by the forced action. Do not call forcedLoadDataAction or
doRequestAction from this render hook: those actions request fresh work and
can create a new Promise on every render.
The version subscription deliberately chooses Suspense refresh behavior. If a
component does not subscribe to requestVersionSelector, a forced request can
run in the background while the existing response remains visible. The
component then renders the new response when its data selector changes. An
unrelated render during that pending request can still reach use() and
suspend, so subscribe to the version when the refresh must reliably show the
nearest Suspense fallback.
See the complete
React use(promise) SPA example.
Requests With Parameters
Use serializeRequestParameters when one logical request has separate cached
states for different parameters.
import { requestsFactory } from 'redux-requests-factory';
const loadUserPostsRequest = ({ userId }, { signal }) =>
fetch(`https://mysite.com/posts?userId=${userId}`, { signal }).then((res) =>
res.json()
);
export const {
loadDataAction: loadUserPostsAction,
forcedLoadDataAction: reloadUserPostsAction,
responseSelector: userPostsSelector,
isLoadingSelector: isLoadingUserPostsSelector,
} = requestsFactory({
request: loadUserPostsRequest,
stateRequestKey: 'user-posts',
serializeRequestParameters: ({ userId }) => `${userId}`,
transformResponse: (response) => response || [],
});When serializeRequestParameters is used, selectors return a function that
accepts the same params.
const postsByUser = useSelector(userPostsSelector);
const isLoadingByUser = useSelector(isLoadingUserPostsSelector);
postsByUser({ userId: 1 });
isLoadingByUser({ userId: 1 });
dispatch(loadUserPostsAction({ userId: 1 }));
dispatch(reloadUserPostsAction({ userId: 1 }));The request state is stored by serialized key:
{
requests: {
responses: {
'user-posts': {
'1': {
status: 'success',
requestVersion: 1,
response: []
}
}
}
}
}requestVersion is internal execution metadata and remains optional for
manually created or older hydrated state. requestVersionSelector normalizes a
missing value to 0.
Updating Cached Data
Use fulfilledActions and rejectedActions to dispatch additional actions
after a request finishes.
import { requestsFactory } from 'redux-requests-factory';
import { setUserPostsAction, userPostsSelector } from './posts-by-user';
const addPostRequest = ({ userId, title, body }, { signal }) =>
fetch('https://mysite.com/posts', {
signal,
method: 'POST',
body: JSON.stringify({ userId, title, body }),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
}).then((res) => res.json());
export const {
doRequestAction: addPostAction,
cancelRequestAction: cancelAddPostAction,
isLoadingSelector: isAddingPostSelector,
} = requestsFactory({
request: addPostRequest,
stateRequestKey: 'add-post',
includeInGlobalLoading: false,
serializeRequestParameters: ({ userId }) => `${userId}`,
fulfilledActions: [
({ response, request: { userId }, state }) =>
setUserPostsAction({
response: [...userPostsSelector(state)({ userId }), response],
params: { userId },
}),
],
});setResponseAction and setErrorAction can also be dispatched directly when
you need to write request state manually.
dispatch(
setResponseAction({
response: { id: 1, name: 'Ada' },
params: { id: 1 },
})
);
dispatch(
setErrorAction({
error: new Error('Request failed'),
params: { id: 1 },
})
);Global Loading
Use isSomethingLoadingSelector to read global request activity.
import { useSelector } from 'react-redux';
import { isSomethingLoadingSelector } from 'redux-requests-factory';
const isSomethingLoading = useSelector(isSomethingLoadingSelector);Requests are included in global loading by default. Disable that per request:
requestsFactory({
request: saveDraftRequest,
stateRequestKey: 'save-draft',
includeInGlobalLoading: false,
});Or disable it for one dispatch with silent.
dispatch(loadDataAction(params, { silent: true }));
dispatch(forcedLoadDataAction(params, { silent: true }));
dispatch(doRequestAction(params, { silent: true }));
dispatch(cancelRequestAction(params, { silent: true }));Use globalLoadingTimeout to remove long-running requests from global loading
after a timeout without cancelling them.
requestsFactory({
request: loadReportRequest,
stateRequestKey: 'report',
globalLoadingTimeout: 1000,
});API Reference
createRequestsFactoryMiddleware(config?)
Creates the Redux middleware and helpers for awaiting or canceling requests started through that middleware instance.
import {
createRequestsFactoryMiddleware,
RequestsStatuses,
} from 'redux-requests-factory';
const { middleware, toPromise, cancelAllRequests } =
createRequestsFactoryMiddleware({
globalLoadingEnabled: false,
loadDataRetryStatuses: [],
loadDataHydratedRetryStatuses: [
RequestsStatuses.Failed,
RequestsStatuses.Canceled,
],
});| Option | Default | Description |
| ------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| globalLoadingEnabled | true | Enables global loading tracking for requests handled by this middleware. A request factory can override this setting. |
| loadDataRetryStatuses | [Failed, Canceled] | Terminal statuses on which loadDataAction starts another request. A request factory can override this setting. |
| loadDataHydratedRetryStatuses | loadDataRetryStatuses | Terminal statuses imported through preloaded state or hydration that loadDataAction may retry once per request key and hydration cycle. |
| forwardFactoryActions | false | Forwards factory command actions as plain Redux actions to later middleware and reducers when enabled. Internal request lifecycle actions are unaffected. |
The request factory setting takes precedence over the middleware setting. Use
an empty array to keep failed and canceled states cached until an explicit
forcedLoadDataAction or resetRequestAction:
const users = requestsFactory({
request: loadUsers,
stateRequestKey: 'users',
loadDataRetryStatuses: [],
loadDataHydratedRetryStatuses: [
RequestsStatuses.Failed,
RequestsStatuses.Canceled,
],
});Add middleware to the Redux middleware chain. Calling toPromise() waits for
the requests tracked by this middleware instance. Dispatching one factory
command returns that command's own Promise<void>, which is usually preferable
when only one request must be awaited.
Calling cancelAllRequests() cancels every active execution tracked by this
middleware instance, aborts each request's AbortSignal, settles their dispatch
Promises, and marks their cached Promise entries as settled. It returns a
Promise<void> so server cleanup can be awaited. Requests dispatched afterward
follow the configured retry policy.
Re-dispatching a retained settled Promise does not add it back to the active
set observed by toPromise().
By default, command actions such as loadDataAction stay out of later
middleware, reducers, loggers, and Redux DevTools. Override the setting for one
supported command with its forwardFactoryAction option.
requestsFactory(config)
Creates request-specific actions and selectors.
import {
requestsFactory,
RequestsStatuses,
} from 'redux-requests-factory';
const request = requestsFactory({
request: ({ id }, { signal }) =>
fetch(`https://mysite.com/api/users/${id}`, { signal }).then((res) =>
res.json()
),
stateRequestKey: 'user',
serializeRequestParameters: ({ id }) => `${id}`,
transformResponse: (response) => response || null,
transformError: (error) => error && error.message,
useDebounce: true,
debounceWait: 500,
debounceOptions: {
leading: true,
trailing: false,
maxWait: 500,
},
stringifyParamsForDebounce: ({ id }) => `${id}`,
includeInGlobalLoading: true,
staleTime: 30_000,
loadDataRetryStatuses: [],
loadDataHydratedRetryStatuses: [
RequestsStatuses.Failed,
RequestsStatuses.Canceled,
],
retry: {
maxRetries: 2,
delay: ({ attempt }) => 500 * 2 ** (attempt - 1),
},
globalLoadingTimeout: 1000,
dispatchFulfilledActionForLoadedRequest: false,
fulfilledActions: [],
rejectedActions: [],
});Config
| Option | Required | Default | Description |
| ----------------------------------------- | -------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| request | Yes | - | Function that receives params plus { signal } and returns a Promise. Pass the signal to abortable work such as fetch. |
| stateRequestKey | Yes | - | Unique key for this request in Redux state. |
| serializeRequestParameters | No | - | Converts params to a string cache key. When set, selectors return (params) => value. |
| transformResponse | No | - | Transforms responseSelector output. Commonly used to provide a default value. |
| transformError | No | - | Transforms errorSelector output and failed-request errors passed to requestRejectedAction and rejectedActions. |
| retry | No | - | Automatically retries failures within one request lifecycle. Configures maxRetries, optional delay, and optional shouldRetry. |
| useDebounce | No | false | Enables debounce for doRequestAction, forcedLoadDataAction, and loadDataAction. |
| debounceWait | No | 500 | Debounce wait in milliseconds. |
| debounceOptions | No | { leading: true, trailing: false, maxWait: debounceWait } | Options passed to lodash.debounce. |
| stringifyParamsForDebounce | No | JSON.stringify | Converts params to a debounce key. |
| fulfilledActions | No | [] | Actions or action factories dispatched after success. |
| rejectedActions | No | [] | Actions or action factories dispatched after failure. |
| includeInGlobalLoading | No | Middleware globalLoadingEnabled, otherwise true | Includes this request in isSomethingLoadingSelector and overrides the middleware setting. |
| staleTime | No | Infinity | Time in ms for which a successful response stays fresh. A normal load after this interval refetches while retaining the cached response during loading. |
| loadDataRetryStatuses | No | [Failed, Canceled] | Terminal statuses on which loadDataAction retries. Overrides the middleware setting; use [] to disable automatic terminal-state retries. |
| loadDataHydratedRetryStatuses | No | loadDataRetryStatuses | Hydrated terminal statuses that may retry once per request key and hydration cycle. Overrides the middleware setting. |
| globalLoadingTimeout | No | - | Removes this request from global loading after the given time in ms. |
| dispatchFulfilledActionForLoadedRequest | No | false | When requestFulfilledAction is enabled, re-dispatches it and fulfilledActions for a cached successful loadDataAction. |
Automatic retries
The optional retry policy repeats a failed request call without creating a
new Redux request lifecycle. maxRetries counts retries after the initial
attempt, so maxRetries: 2 permits at most three calls. Intermediate failures
keep the request in Loading; only the final failure updates Redux and
dispatches requestRejectedAction and rejectedActions. Global loading and
the Promise returned by dispatch also cover the complete sequence rather than
each attempt separately.
const users = requestsFactory({
stateRequestKey: 'users',
request: loadUsers,
transformError: (error: unknown) => normalizeApiError(error),
retry: {
maxRetries: 3,
shouldRetry: ({ error }) =>
error.status === 429 || error.status >= 500,
delay: ({ attempt }) => {
const exponentialDelay = Math.min(500 * 2 ** (attempt - 1), 10_000);
return exponentialDelay * (0.8 + Math.random() * 0.4);
},
},
});attempt is the one-based number of the request call that failed, and
retriesLeft is the number of retries still available after that failure. Both
callbacks receive the request params and the error produced by transformError.
A numeric delay applies the same wait to every retry. Negative and non-finite
retry counts are treated as zero; negative and non-finite delays are treated as
zero.
Cancellation aborts the active request or clears a pending retry delay, and no
further attempt is started. loadDataRetryStatuses remains a separate policy:
after automatic retries are exhausted and the request becomes Failed, it
decides whether a later loadDataAction dispatch may begin a new lifecycle.
AbortController compatibility
The request context uses the exported RequestContext type:
import {
requestsFactory,
type RequestContext,
} from 'redux-requests-factory';
const loadUsersRequest = (
_params: undefined,
{ signal }: RequestContext
) => fetch('/api/users', { signal });RequestContext.signal is optional because the library also runs in
environments without a global AbortController. In those environments,
cancelRequestAction and cancelAllRequests() still update Redux, settle the
dispatch Promises, settle their cache entries, and ignore late results, but
they cannot physically stop the underlying transport.
Install and initialize an AbortController polyfill before creating the store
when transport-level cancellation is required in such a runtime. The library's
ES5 build target transpiles syntax; it does not polyfill browser or server
globals. Request implementations should always forward signal to fetch or
other abort-aware work—the standard APIs safely accept undefined.
fulfilledActions receive { request, response, state }.
rejectedActions receive { request, error, state }.
Each item can be an action, null, an array of actions or nulls, or a
function that returns one of those values.
requestsFactory({
request: loadUserRequest,
stateRequestKey: 'user',
fulfilledActions: [
{ type: 'HIDE_NOTIFICATION' },
({ response }) =>
response ? { type: 'USER_LOADED', payload: response } : null,
],
rejectedActions: [
({ error }) => ({
type: 'SHOW_NOTIFICATION',
payload: error.message,
}),
],
});Actions
| Action creator | Behavior |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| loadDataAction(params?, options?) | Reuses the latest pending or settled Promise and starts work only when freshness, loadDataRetryStatuses, or loadDataHydratedRetryStatuses requires it. |
| forcedLoadDataAction(params?, options?) | Bypasses the successful-response cache. Use it for reload flows. |
| doRequestAction(params?, options?) | Bypasses the successful-response cache. Use it for create, update, and delete flows. |
| cancelRequestAction(params?, options?) | Marks the latest active request as canceled, aborts work using its signal, and ignores any eventual result. |
| setErrorAction({ error, params? }) | Writes an error state and dispatches requestRejectedAction when that lifecycle action is enabled. |
| setResponseAction({ response, params? }) | Writes a success state and dispatches requestFulfilledAction when that lifecycle action is enabled. |
| resetRequestAction(params?) | Resets status to RequestsStatuses.None and clears response and error. |
| requestFulfilledAction | Plain action creator for subscriptions. Reading it enables fulfilled lifecycle dispatches. Payload is { params, response }. |
| requestRejectedAction | Plain action creator for subscriptions. Reading it enables rejected lifecycle dispatches. Payload is { params, error }. |
Lazy lifecycle actions
requestFulfilledAction and requestRejectedAction are lazy. The factory
dispatches each lifecycle action only after its action creator has been read
from that factory instance. This avoids dispatching actions when no reducer,
epic, saga, or other middleware subscribes to them.
Destructuring an action creator enables its lifecycle dispatches:
const { requestFulfilledAction } = requestsFactory({
request: loadUserRequest,
stateRequestKey: 'user',
});After this property access, successful requests and setResponseAction
dispatch requestFulfilledAction. The flag remains enabled for the lifetime of
that factory instance. The same behavior applies to requestRejectedAction,
failed requests, and setErrorAction.
Do not spread the factory result. Spreading reads every enumerable property and therefore enables both lifecycle actions even if the copied properties are never used:
// BAD: Do not spread a factory result. This enables every lazy lifecycle action.
const allFactoryActions = { ...requestsFactory(config) };Instead, destructure only the actions and selectors that are actually needed:
const { loadDataAction, requestFulfilledAction, responseSelector } =
requestsFactory(config);loadDataAction reuses the latest Promise for the same request key while it is
pending and after it settles. This also applies when that request was started
by a forced or direct request action. Forced and direct actions bypass the
successful-response cache, request fresh work, and replace the Promise reused
by later normal loads. A normal stale or configured retry also replaces it.
When a new middleware receives already cached state without a runtime Promise,
its first normal load creates one stable fulfilled Promise. When useDebounce
is enabled, all three request-starting actions remain subject to the configured
debounce behavior.
The stable latest Promise cache, cancellation bookkeeping, and debounce state
belong to the middleware instance returned by
createRequestsFactoryMiddleware(). They are not stored globally in the
request factory. Reusing singleton request actions across several stores is
therefore safe as long as every store gets its own middleware instance, which
is also the required setup for SSR.
Internally, each requestsFactory(config) call creates one stable request
factory runtime key. The key is only an identity token; it contains no mutable
state. Each middleware instance owns a separate WeakMap and stores its own
runtime state under that shared key:
middleware A: requestFactoryRuntimeKey -> runtime state A
middleware B: requestFactoryRuntimeKey -> runtime state BConsequently, the same singleton request actions can be dispatched through several stores without sharing Promise caches or cancellation state.
Dispatch promises represent completion, not response values: they resolve to
void. A rejected request is normally caught by the factory, written to Redux,
and exposed by errorSelector; it is not rethrown through the dispatch Promise.
Render or otherwise handle request errors from Redux state.
cancelRequestAction always changes the request status to Canceled and
prevents a late response or error from updating Redux. It also aborts the
AbortSignal passed to the request. Forward that signal to fetch or another
abort-aware client to stop the underlying work:
const { loadDataAction, cancelRequestAction } = requestsFactory({
stateRequestKey: 'user',
request: ({ id }, { signal }) =>
fetch(`/api/users/${id}`, { signal }).then((response) => response.json()),
});If the request implementation ignores the signal, its underlying work may
continue, but cancellation still settles the dispatch Promise immediately and
marks its cached entry as settled. A new loadDataAction replaces that entry
and starts work when Canceled is enabled by the applicable retry policy. Any
eventual response or error from the canceled execution is ignored.
resetRequestAction changes only Redux request state. It does not cancel an
active request, so that request can still write its result afterward. Cancel
the active request first when its eventual result must be ignored, then reset
the state if None is the desired final status.
loadDataAction, forcedLoadDataAction, doRequestAction, and
cancelRequestAction accept an options object:
| Option | Behavior |
| ---------------------- | ------------------------------------------------------------- |
| silent | Excludes a started request from global loading state updates. |
| forwardFactoryAction | Overrides forwardFactoryActions for this dispatch only. |
cancelRequestAction remembers whether the active execution was silent, so it
updates the global loading counter correctly without repeating the original
silent option.
dispatch(loadDataAction({ id: 1 }));
dispatch(forcedLoadDataAction({ id: 1 }));
dispatch(doRequestAction({ id: 1 }, { silent: true }));
dispatch(loadDataAction({ id: 1 }, { forwardFactoryAction: true }));
dispatch(cancelRequestAction({ id: 1 }));
dispatch(resetRequestAction({ id: 1 }));Integration with Redux Observable, Redux Saga, and other consumers
The built-in requests reducer does not require requestFulfilledAction or
requestRejectedAction. They are optional, request-specific integration
events for code outside the factory. Use them when an epic, saga, custom
middleware, or application reducer needs to react after a request succeeds or
fails.
Typical uses include starting a dependent workflow, updating another Redux
slice, showing a notification, redirecting, or reporting analytics. Fulfilled
actions carry { params, response }.
Rejected actions carry { params, error }. Errors from failed requests are
processed by transformError; setErrorAction forwards the error value passed
to it.
With Redux Observable, an epic can subscribe to the fulfilled action:
import { ofType } from 'redux-observable';
import { ignoreElements, tap } from 'rxjs/operators';
export const { requestFulfilledAction } = requestsFactory({
request: loadUserRequest,
stateRequestKey: 'user',
});
const userLoadedEpic = (action$) =>
action$.pipe(
ofType(requestFulfilledAction.type),
tap(({ payload: { params, response } }) => {
console.log('User loaded', params, response);
}),
ignoreElements()
);With Redux Saga, a saga can subscribe to the rejected action:
import { put, takeEvery } from 'redux-saga/effects';
export const { requestRejectedAction } = requestsFactory({
request: loadUserRequest,
stateRequestKey: 'user',
});
function* handleUserLoadRejected({ payload: { error } }) {
yield put({
type: 'SHOW_NOTIFICATION',
payload: error.message,
});
}
export function* userRequestsSaga() {
yield takeEvery(requestRejectedAction.type, handleUserLoadRejected);
}Reading these action creators for the subscription enables their lazy dispatches for that factory instance. If no external consumer needs them, do not destructure them and the factory leaves those actions out of the Redux stream.
Selectors
| Selector | Without serializeRequestParameters | With serializeRequestParameters |
| ------------------------ | ------------------------------------ | ------------------------------------- |
| responseSelector | state => response | state => params => response |
| errorSelector | state => error | state => params => error |
| requestStatusSelector | state => RequestsStatuses | state => params => RequestsStatuses |
| requestVersionSelector | state => number | state => params => number |
| isLoadingSelector | state => boolean | state => params => boolean |
| isLoadedSelector | state => boolean | state => params => boolean |
Available statuses:
import { RequestsStatuses } from 'redux-requests-factory';
RequestsStatuses.None;
RequestsStatuses.Loading;
RequestsStatuses.Success;
RequestsStatuses.Failed;
RequestsStatuses.Canceled;responseSelector returns undefined until a response exists unless
transformResponse provides another value.
errorSelector similarly returns undefined until an error exists unless
transformError provides another value.
requestVersionSelector returns the stored execution version, or 0 before a
request has started. Each actual request start increments it; a cached
loadDataAction dispatch does not. This provides a precise subscription for
consumers that must observe replacement of the Promise returned by
loadDataAction, including forced Loading -> Loading executions. With
serializeRequestParameters, call the returned params selector to read the
version for one serialized request key.
const { responseSelector } = requestsFactory({
request: loadUsersRequest,
stateRequestKey: 'users',
transformResponse: (response) => response || [],
});
responseSelector(state); // []Request-state hydration
requestsStateSelector returns the complete requests slice without exposing
the configured stateRequestsKey:
const requestsState = requestsStateSelector(store.getState());Dispatch hydrateRequestsAction to merge that slice into another store using
the standard requestsReducer:
store.dispatch(hydrateRequestsAction(requestsState));The reducer applies these rules:
- the receiving store keeps its current global loading count;
- different
stateRequestKeyvalues are merged; - different
serializedKeyvalues for a parameterized request are merged; - the same
stateRequestKeyandserializedKeyidentity is replaced by the hydrated request state.
Middleware tracks hydration separately for each request identity. When a
terminal status is received through initial preloaded state or
hydrateRequestsAction, loadDataHydratedRetryStatuses can allow exactly one
normal retry for that request key in the new hydration cycle. After that
attempt, further terminal states are governed by loadDataRetryStatuses. This
supports retrying an SSR failure or an SSR-canceled request once in the browser
without repeatedly retrying a client-side failure or cancellation:
createRequestsFactoryMiddleware({
loadDataRetryStatuses: [],
loadDataHydratedRetryStatuses: [
RequestsStatuses.Failed,
RequestsStatuses.Canceled,
],
});The action creator is tied to its factory instance. When multiple custom factories are mounted in one store, only the matching reducer handles the hydration action.
Custom Factory Instances
Use the default export when you need more than one independent request factory, for example to store request states under different reducer keys.
import createReduxRequestsFactory from 'redux-requests-factory';
export const apiOne = createReduxRequestsFactory({
stateRequestsKey: 'apiOne',
});
export const apiTwo = createReduxRequestsFactory({
stateRequestsKey: 'apiTwo',
});Each instance provides the same API:
const {
stateRequestsKey,
createRequestsFactoryMiddleware,
requestsFactory,
requestsReducer,
isSomethingLoadingSelector,
requestsStateSelector,
hydrateRequestsAction,
} = createReduxRequestsFactory({
stateRequestsKey: 'api',
});SSR
Dispatching a request-factory action returns a promise for that action. Await it when the next step depends on one specific request:
await store.dispatch(loadUsersAction());createRequestsFactoryMiddleware() also returns lifecycle helpers for the
requests owned by that middleware instance. toPromise() resolves when all
currently tracked requests finish. cancelAllRequests() cancels all of them,
which is useful when an SSR render or its HTTP connection is aborted.
The middleware performs the registration automatically when a factory action is dispatched. Components, request modules, and route loaders do not need to return their handles to a central coordinator. This remains true when the set of requests is discovered dynamically during rendering or after another request resolves.
import {
createRequestsFactoryMiddleware,
RequestsStatuses,
} from 'redux-requests-factory';
const makeStore = (initialState) => {
const { middleware, toPromise, cancelAllRequests } =
createRequestsFactoryMiddleware({
loadDataRetryStatuses: [],
loadDataHydratedRetryStatuses: [
RequestsStatuses.Failed,
RequestsStatuses.Canceled,
],
});
const store = createStore(reducer, initialState, applyMiddleware(middleware));
store.asyncRequests = toPromise;
store.cancelAsyncRequests = cancelAllRequests;
return store;
};Call createRequestsFactoryMiddleware() inside makeStore. Every SSR request
then gets its own store, middleware, tracked-request set, stable latest Promise
cache, cancellation state, and debounce state. Request action modules can stay
as application-level singletons without leaking runtime state between server
requests. Their stable request factory keys identify the request definition in
each middleware's private runtime map; the keys themselves do not hold cache
data.
For SSR applications, the configuration above is a useful retry boundary:
- a failed or canceled request is not started again inside the same server render;
- a new browser middleware may retry that terminal state once after receiving
it through preloaded state or
hydrateRequestsAction; - if the client attempt also fails or is canceled, normal loads keep that state
terminal and an explicit
forcedLoadDataActionis required.
The library does not detect server and browser globals. It distinguishes the
server attempt from the client retry through store-scoped request history and
per-request hydration generations. This also works for repeated hydration and
for individual serializeRequestParameters keys.
Connect the HTTP or framework lifecycle to the store when it is available. For example, an Express handler can create one controller for its SSR render:
const renderAbortController = new AbortController();
response.once('close', () => {
if (!response.writableEnded) {
renderAbortController.abort();
}
});
renderAbortController.signal.addEventListener(
'abort',
() => void store.cancelAsyncRequests(),
{ once: true }
);
await renderPage({ store });Global cancellation is scoped to this store's middleware instance. It cancels requests from every request factory and serialized key in that render, including concurrent forced executions, without affecting another SSR store. It also settles their dispatch promises and cached entries, so the server lifecycle can await cleanup before discarding the store.
Canceling requests throughout the SSR lifecycle
Forward the RequestContext.signal to every transport used during SSR. Calling
store.cancelAsyncRequests() then aborts those transports, settles the
middleware promises and their cache entries, and prevents late responses from
changing the Redux state.
Cancellation should cover each way an SSR render can stop:
- HTTP connection closed: connect the response or request
closeevent to the render'sAbortController, and connect that signal tostore.cancelAsyncRequests(). - Preloading or rendering throws: await
store.cancelAsyncRequests()in afinallyblock. Removing an abort listener is not enough; it only prevents future notifications and does not stop work that is already running. - Streaming render aborted: call both React's stream
abort()andstore.cancelAsyncRequests(). Also cancel from terminal error callbacks such asonShellError. A stream outlives the function that creates it, so its cleanup belongs to stream and HTTP lifecycle callbacks rather than an outerfinallythat runs as soon as the stream handle is returned.
For a non-streaming handler, the cleanup can follow this shape:
const cancelRequests = () => void store.cancelAsyncRequests();
response.once('close', cancelRequests);
try {
await preloadRequests(store);
return renderPage(store);
} finally {
await store.cancelAsyncRequests();
response.off('close', cancelRequests);
}In a component-driven two-pass render, requests may also be discovered during the second pass—for example, when data from the first pass reveals another component subtree. Produce the final HTML, cancel those executions, and only then read the state that will be sent to the browser:
renderToString(app); // discovery pass
await store.asyncRequests();
const appHtml = renderToString(app); // final pass
await store.cancelAsyncRequests();
const preloadedState = store.getState();The ordering matters. Hydrating an orphaned loading status would prevent a
normal loadDataAction from starting because the browser has no corresponding
server execution. Cancellation stores canceled for active requests. With
Canceled in loadDataHydratedRetryStatuses, a client-side load effect can
start each of them once after hydration. The same policy can retry hydrated
failed state. Requests that already completed successfully keep their
success status and cached response.
In Next.js Pages Router, dispatch request actions in getServerSideProps and
then wait for them before returning props.
export const getServerSideProps = wrapper.getServerSideProps(
(store) => async ({ res }) => {
const cancelRequests = () => void store.cancelAsyncRequests();
res.once('close', cancelRequests);
try {
await store.dispatch(loadUsersAction());
return {
props: {},
};
} finally {
await store.cancelAsyncRequests();
res.off('close', cancelRequests);
}
}
);You can also chain dependent requests by reading the Redux state after the first await.
export const getServerSideProps = wrapper.getServerSideProps(
(store) => async ({ res }) => {
const cancelRequests = () => void store.cancelAsyncRequests();
res.once('close', cancelRequests);
try {
await store.dispatch(loadUsersAction());
const users = usersSelector(store.getState());
users.forEach(({ id }) => {
store.dispatch(loadUserPostsAction({ userId: id }));
});
await store.asyncRequests();
return {
props: {},
};
} finally {
await store.cancelAsyncRequests();
res.off('close', cancelRequests);
}
}
);See the Next.js Pages Router example
for a complete setup with next-redux-wrapper.
For a new framework-free React 19 SSR integration, start with the
modern streaming SSR example.
It is the primary SSR reference in this repository and demonstrates the full
document lifecycle with renderToPipeableStream, component-owned data loading,
use(promise), independent Suspense boundaries, progressive HTML streaming,
Redux state transfer, hydrateRoot, and React Router navigation.
React renderToString
The classic React SSR examples show two ways to decide which requests must finish before HTML is returned:
| Approach | Server flow | Best fit |
| ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Route-level preloading | Dispatch known route actions, await them, render once | Routes with an explicit data-loading contract |
| Component-driven loading | Discovery render, await store.asyncRequests(), result render | Pages where nested components own and dynamically declare their requests |
In the preloading approach, the server entry knows all data required by the
route. In the component-driven approach, a loading hook dispatches immediately
during server rendering and uses useEffect in the browser. The first server
render mounts only the requested route and discovers its actions; after the
aggregate promise settles, the second render reads the loaded state and its HTML
is sent to the client.
React renderToPipeableStream
The [modern Suspense and `u
