mrkeyoor.com_
Sat 08 Aug 21:58 UTC
npmWeb Frontendupdated 08 Aug 2026

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.

Verdict

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.

API stability4/5The package exposes one two-stage HOC factory and a generated peek method, with no broad option surface to churn. Moving registration to committed client lifecycles gives the design a clearer boundary than react-side-effect. The API is still informal in places: README signature text does not match the two-argument source, types are absent, and precedence is observable mount order rather than an explicit contract.
Docs2/5The README explains the aggregation idea and shows body-style and document-title reducers, but it repeatedly imports react-side-effect instead of this package and mentions a server mapper the source does not accept. It omits TypeScript, ES module usage, Strict Mode calls, empty-state cleanup, PureComponent mutation hazards, ref behavior, multi-root sharing, and the difference between mount order and tree order.
Maintenance4/5Version 1.2.8 shipped in May 2025, the repository was pushed the same day, and the preceding release explicitly added React 19 to the peer range. There are no open issues or pull requests in GitHub's combined count. Confidence is capped because the development suite and dependencies still target React 15-era Enzyme and Mocha tooling rather than exercising the declared React 18 and 19 behavior.
Ecosystem3/5The package recorded 3,326,963 downloads in the measured week and is useful as a transitive primitive beneath focused UI packages. It publishes CommonJS and ES module builds and supports a wide React peer range. Direct adoption remains niche: the repository has no stars, types or plugins are absent, and application developers usually use hooks or a purpose-built document-head or scroll-lock library.

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
Skip it if

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

PackageRegistryPick it when
react-helmet-asyncnpmUse it for document title and head tags that must also work in request-scoped server rendering.
react-side-effectnpmUse it only in established React 18-or-earlier code that specifically depends on peek and rewind during server rendering.
@react-hookz/webnpmUse it when a maintained browser hook can own the effect without aggregating markers across the component tree.