react-clientside-effect
react-clientside-effect is a client-only React higher-order component factory. Each wrapped component instance registers its props after mounting; on mount, update, or unmount, your reducer receives props from all registered instances and your handler applies one browser-global effect. This supports declarative markers for body classes, scroll locks, titles, theme attributes, or similar shared DOM state. It is a client-focused fork of react-side-effect and intentionally does not collect or expose server-rendered state.
A reasonable tiny building block for client-only library components that truly need tree-wide prop aggregation, and more current than react-side-effect. Most applications should start with useEffect or a focused head or scroll-lock package because this HOC leaves precedence, cleanup, types, and Strict Mode behavior to you.
Use it if
- You need several component instances to contribute to one browser-global effect and a single local useEffect cannot see them all
- You maintain a library that exposes declarative marker components for scroll locking, body state, or another shared client concern
- You want React 16.8 through 19 peer support and prefer committed lifecycle effects over the original package's pre-commit lifecycle
- You can define deterministic precedence and cleanup behavior in a small reducer and handler
- One component can own the effect: a normal useEffect with cleanup is clearer, typed by your application, and does not maintain a hidden instance registry
- You need title and meta tags across client and server rendering: react-helmet-async has a purpose-built head model and request-scoped server collection
- You need any server output: the README says this variation does nothing on the server, and the implementation only registers instances in componentDidMount
- You need tree-position precedence: the reducer sees registration order, not React ancestry, so portals, conditional mounts, and reordered trees can make last-item-wins semantics misleading
- You require included TypeScript declarations, ref forwarding, or a hooks API: 1.2.8 ships JavaScript only, spreads props into a generated class, and exposes no ref-forwarding option
Setup reality
npm install react-clientside-effect adds @babel/runtime and requires an existing React version in the declared range from 16.8 through 19. There is no native build, account, credential, provider, or config file. Both CommonJS and an ES module build are published, and package.json marks the package side-effect-free for bundlers. It does not include TypeScript declarations, so typed projects must write a small local declaration or accept any from a community source they have verified. The README currently contains copy errors: examples import react-side-effect instead of react-clientside-effect, and the signature mentions a server mapper that the implementation does not accept. Use the two-argument factory from the source. You supply a pure reducePropsToState function, a client handler, and a component to wrap. The generated PureComponent registers in componentDidMount, recalculates after updates, removes itself on unmount, and exposes static peek for tests. No handler runs during server rendering, so server markup cannot obtain the aggregate. Each call to the HOC factory owns one closure-level mounted-instance array, shared across every use and even separate React roots using that generated component. Registration order is not a formal tree traversal; if precedence matters, put a numeric priority in props and reduce it explicitly. The handler must be idempotent and handle an empty list by undoing old body styles, classes, listeners, or locks. Otherwise the last unmount leaves stale global state. React Strict Mode can mount, unmount, and mount again in development, so expect extra handler calls and never make them one-way actions such as incrementing a counter. Because the wrapper extends PureComponent, in-place mutation of a prop object can suppress updates; pass new objects. Refs target neither the wrapped component nor a forwarded handle automatically.
Patterns
Create a client-only title markercreate-document-title
import withClientSideEffect from 'react-clientside-effect';
function TitleMarker() { return null; }
export const DocumentTitle = withClientSideEffect(
(items) => items.at(-1)?.title ?? '',
(title) => { document.title = title; }
)(TitleMarker);The README examples import the original package by mistake. Import react-clientside-effect as shown here.
Declare a title inside a screenrender-title-marker
function BillingScreen() {
return (
<>
<DocumentTitle title="Billing" />
<main>...</main>
</>
);
}The effect appears only after the marker commits on the client. Server-rendered HTML receives no title state from this package.
Merge body styles from several markersmerge-body-styles
const BodyStyle = withClientSideEffect(
(items) => Object.assign({}, ...items.map((item) => item.style)),
(style) => {
document.body.style.backgroundColor = style.backgroundColor || '';
document.body.style.overflow = style.overflow || '';
}
)(() => null);Reset every managed property when the list becomes empty or a value from an unmounted screen will remain on the body.
Lock scrolling while any modal is openlock-body-scroll
const ScrollLock = withClientSideEffect(
(items) => items.some((item) => item.enabled),
(locked) => {
document.body.style.overflow = locked ? 'hidden' : '';
}
)(() => null);
<ScrollLock enabled={isModalOpen} />This simple version does not preserve scrollbar width, iOS touch behavior, or a pre-existing inline overflow value. Use a focused lock package for those cases.
Toggle a shared body classtoggle-body-class
const BodyFlag = withClientSideEffect(
(items) => items.some((item) => item.active),
(active) => document.body.classList.toggle('has-overlay', active)
)(() => null);The boolean form of classList.toggle is idempotent, which matters when Strict Mode causes extra mount and unmount cycles in development.
Choose a document theme with explicit priorityset-theme-attribute
const ThemeMarker = withClientSideEffect(
(items) => items.reduce(
(best, item) => !best || item.priority > best.priority ? item : best,
null
)?.theme ?? null,
(theme) => {
if (theme) document.documentElement.dataset.theme = theme;
else delete document.documentElement.dataset.theme;
}
)(() => null);A priority prop is safer than assuming the last registered marker is deepest in the React tree.
Aggregate a controlled set of classesaggregate-class-names
const managed = new Set(['route-dark', 'route-wide', 'route-print']);
const BodyClasses = withClientSideEffect(
(items) => new Set(items.flatMap((item) => item.classes || [])),
(active) => {
for (const name of managed) {
document.body.classList.toggle(name, active.has(name));
}
}
)(() => null);Manage a fixed allowlist so cleanup does not remove unrelated classes owned by the application or browser extensions.
Create a marker that preserves one childwrap-children
import { Children } from 'react';
function Marker({ children }) {
return children ? Children.only(children) : null;
}
const BodyMode = withClientSideEffect(
(items) => items.at(-1)?.mode ?? 'default',
applyBodyMode
)(Marker);Children.only throws for multiple siblings. A null-rendering marker is simpler when wrapping content is not part of the public API.
Model the empty state explicitlycleanup-empty-state
const reduceBanner = (items) =>
items.length ? items[items.length - 1].message : null;
const applyBanner = (message) => {
if (message) showGlobalBanner(message);
else hideGlobalBanner();
};
const BannerMarker = withClientSideEffect(reduceBanner, applyBanner)(() => null);The handler is called after the final marker unmounts. Use that empty reduction to reverse the effect.
Inspect reduced state in a testinspect-in-test
const view = render(
<>
<BodyFlag active={false} />
<BodyFlag active />
</>
);
expect(BodyFlag.peek()).toBe(true);
view.unmount();peek does not clear state. Always unmount the rendered tree so the instance registry and global effect are cleaned up.
Replace prop objects instead of mutating thempass-immutable-props
const style = useMemo(
() => ({ backgroundColor: dark ? '#111' : '#fff' }),
[dark]
);
return <BodyStyle style={style} />;The generated wrapper extends PureComponent. Mutating the same style object in place can prevent componentDidUpdate and leave the effect stale.
Add a minimal local TypeScript declarationdeclare-types
declare module 'react-clientside-effect' {
import type { ComponentType } from 'react';
export default function withClientSideEffect<P, S>(
reduce: (props: P[]) => S,
apply: (state: S) => void
): (component: ComponentType<P>) => ComponentType<P> & { peek(): S };
}The package does not ship declarations. Keep a local declaration close to the exact API your project uses and test it on upgrades.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-helmet-async | npm | Use it for document title and head tags that must also work in request-scoped server rendering. |
| react-side-effect | npm | Use it only in established React 18-or-earlier code that specifically depends on peek and rewind during server rendering. |
| @react-hookz/web | npm | Use it when a maintained browser hook can own the effect without aggregating markers across the component tree. |