react-side-effect
react-side-effect is a small React higher-order component factory for turning props from every mounted instance of a component into one shared browser side effect. You supply a reducer, a client callback, and optionally a server mapper. It is the low-level mechanism behind patterns such as choosing the innermost document title or combining body styles. It predates hooks and keeps its mounted-instance list in the closure created for each wrapped component.
Keep it when you are maintaining an established declarative side-effect component on React 18 or earlier. Do not choose it for a new React 19 or concurrent-rendering design; its legacy lifecycle and shared SSR cleanup contract are unnecessary risks today.
Use it if
- You maintain an existing React 16, 17, or 18 component library that already exposes a declarative side-effect component
- You need to aggregate props from several mounted component instances before changing one browser-global value
- You need the same tiny abstraction to expose collected state after renderToString on a strictly sequential server renderer
- You are repairing a package built on this exact HOC and replacing it would change a public API
- You are starting a React 19 application: version 2.1.2 declares React 16.3 through 18 as its peer range, and the repository has an open React 19 dependency-warning report
- You use concurrent rendering or Strict Mode as a correctness check: the implementation registers instances in UNSAFE_componentWillMount, a render-phase legacy lifecycle with a long-running open issue
- You render server requests concurrently: the README requires rewind after every render to clear shared state, and warns that forgetting it causes a memory leak and incorrect information
- You only need document head tags: react-helmet-async provides a purpose-built, request-scoped API instead of making you design the reducer and cleanup behavior
- You want first-party TypeScript types, an ESM entry point, or a hooks API: the published metadata exposes CommonJS only and includes no types
Setup reality
Installation is only npm install react-side-effect, with no runtime dependencies, native build, credentials, or configuration file. The easy install hides the integration work. Version 2.1.2 declares React ^16.3, ^17, or ^18 as a peer, so npm can reject or warn in a React 19 tree. The package ships CommonJS and no TypeScript declarations; TypeScript projects normally add @types/react-side-effect and may need esModuleInterop for a default import. You must create a wrapper component, decide how every mounted instance is reduced, decide what an empty list means, and undo stale browser state when the last instance unmounts. The implementation uses PureComponent, UNSAFE_componentWillMount, and a closure-level array of instances. That legacy lifecycle is a serious fit concern for modern concurrent React. Server rendering has a separate operational rule: call the generated component's rewind() immediately after every renderToString, preferably in a finally block. The README explicitly warns that missing the call leaves the stack growing and returns incorrect state on later requests. peek() does not clear anything and is intended for tests. The client callback never runs on the server, while the optional third mapper changes what rewind returns. There is no built-in precedence model beyond the order your reducer receives, so nested priority, merging, defaults, and cleanup are all application code that needs tests.
Patterns
Create a declarative document title componentcreate-document-title
import React from 'react';
import withSideEffect from 'react-side-effect';
function TitleMarker() {
return null;
}
const reduceTitles = (propsList) =>
propsList.length ? propsList[propsList.length - 1].title : '';
export const DocumentTitle = withSideEffect(
reduceTitles,
(title) => { document.title = title; }
)(TitleMarker);The last mounted instance wins, which often resembles innermost precedence but is based on registration order rather than React tree inspection.
Set a title from a screen componentrender-title-marker
function AccountScreen() {
return (
<>
<DocumentTitle title="Account settings" />
<main>...</main>
</>
);
}The wrapped marker may render null; it does not need to wrap the page content.
Merge body styles from mounted markersmerge-body-styles
function BodyStyleMarker() { return null; }
export const BodyStyle = withSideEffect(
(items) => Object.assign({}, ...items.map((item) => item.style)),
(style) => {
document.body.style.backgroundColor = style.backgroundColor || '';
document.body.style.overflow = style.overflow || '';
}
)(BodyStyleMarker);Explicitly reset each property when no marker supplies it, or a style from an unmounted screen remains on document.body.
Toggle one global body classtoggle-body-class
function BodyClassMarker() { return null; }
export const BodyClass = withSideEffect(
(items) => items.some((item) => item.modalOpen),
(modalOpen) => {
document.body.classList.toggle('has-modal', modalOpen);
}
)(BodyClassMarker);Use classList.toggle with its boolean argument so unmounting the final marker performs cleanup.
Make precedence explicit with a priority propchoose-highest-priority
const reduceByPriority = (items) => {
const selected = items.reduce(
(best, item) => !best || item.priority > best.priority ? item : best,
null
);
return selected?.value ?? null;
};Do this when correctness depends on precedence. Mounted-instance order is not a reliable substitute for a declared priority.
Collect unique values across every markercollect-unique-values
const reduceFlags = (items) => [
...new Set(items.flatMap((item) => item.flags || [])),
];
const FeatureFlags = withSideEffect(
reduceFlags,
(flags) => window.analytics?.setEnabledFlags(flags)
)(() => null);The reducer runs on mount, update, and unmount, so keep it deterministic and inexpensive.
Read and clear state after server renderingextract-server-state
let title;
try {
const html = renderToString(<App />);
title = DocumentTitle.rewind();
return renderPage({ html, title });
} finally {
DocumentTitle.rewind();
}rewind is server-only and clears the instance stack. The final cleanup protects error paths, but concurrent requests can still share the same closure state.
Return a server-specific representationmap-server-state
const ServerAwareTitle = withSideEffect(
(items) => items.at(-1)?.title || '',
(title) => { document.title = title; },
(title) => ({ title, escaped: escapeHtml(title) })
)(() => null);
const titleState = ServerAwareTitle.rewind();The optional third function runs only when the library decides no DOM is available; rewind returns its mapped value.
Inspect aggregated state without clearing itinspect-state-in-test
const view = mount(
<>
<DocumentTitle title="Outer" />
<DocumentTitle title="Inner" />
</>
);
expect(DocumentTitle.peek()).to.equal('Inner');
view.unmount();The README reserves peek for tests. It does not reset the mounted-instance list, so unmount your test tree.
Reset server-mode state between testsreset-test-state
afterEach(() => {
if (!DocumentTitle.canUseDOM) {
DocumentTitle.rewind();
}
});rewind throws in a DOM environment. In browser-like tests, unmount rendered trees instead of calling it.
Load the CommonJS builduse-commonjs
const withSideEffect = require('react-side-effect');
const VisibilityMarker = withSideEffect(
(items) => items.some((item) => item.hidden),
(hidden) => { document.hiddenStateForApp = hidden; }
)(() => null);The package metadata publishes a CommonJS main entry and no module entry.
Add community TypeScript declarationsadd-typescript-types
npm install react-side-effect
npm install --save-dev @types/react-side-effectVersion 2.1.2 does not include its own types. Check that the separate @types package matches the API you use.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-helmet-async | npm | Use it for document title, meta, link, and script tags with request-scoped server rendering. |
| react-clientside-effect | npm | Use it when you need a closely related client-oriented HOC with newer lifecycle choices, after checking its own React compatibility. |
| @react-hookz/web | npm | Use it when one component can own the browser effect and a maintained hook is clearer than tree-wide prop aggregation. |