If you've built anything in React 18+, you've likely felt the pain of Strict Mode.
You add a simple event listener, fire an analytics event, or connect a WebSocket. You boot up your dev server, andβboomβit fires twice.
React intentionally mounts your component, unmounts it, and remounts it in development to help you catch bugs like stale closures, missed cleanups, and memory leaks. It's an incredibly useful feature, but it can make your development console extremely noisy and trigger unexpected side-effects.
Faced with this, developers often end up doing one of three things:
- Disable Strict Mode entirely (and lose out on finding actual bugs).
-
Add
if (!user || !socket) returnguard clauses everywhere. -
Stuff a
useRef(false)flag inside the component to prevent double execution.
What if you could keep Strict Mode on, but express your effect timing declaratively?
Enter @okyrychenko-dev/react-effect-when.
π The Problem with Manual Guards
Let's say you want to connect to a WebSocket, but you need both a userId and an authToken to be ready.
Normally, you'd write something like this:
function RealtimeConnection({ userId, authToken }) {
useEffect(() => {
// 1. Guard against empty state or loading moments
if (!userId || !authToken) {
return;
}
// 2. Do the actual work
const socket = connectSocket({ userId, token: authToken });
// 3. Clean up
return () => socket.close();
}, [userId, authToken]);
}
This works, but as your app grows, this pattern repeats everywhere. The real trigger condition is hidden inside the effect body. And if you want to reduce duplicate development-time noise for a specific effect, a common workaround is adding a useRef flag alongside those conditions.
β¨ The Solution: Declarative Effect Hooks
react-effect-when gives you specialized tools to lift conditions right into the hook's signature. That removes a lot of boilerplate and also provides solid TypeScript narrowing.
Let's take a look at the three main hooks the package provides.
1. useEffectWhenReady: For asynchronous data and services
When you are waiting for data, selectors, or services to load, you typically want to ensure none of them are null or undefined.
import { useEffectWhenReady } from "@okyrychenko-dev/react-effect-when";
function Profile({ user, token }) {
useEffectWhenReady(
([readyUser, readyToken]) => {
// π TypeScript knows these are non-null and non-undefined!
trackProfileView(readyUser.id, readyToken);
},
// The hook naturally tracks these dependencies:
[user, token]
);
}
Why it's better:
- No early returns. The effect only fires when all dependencies are non-null and non-undefined.
-
Built-in TypeScript narrowing.
readyUserandreadyTokenare strictly typed, eliminating the need foruser?.idchecking inside the effect.
2. useEffectWhenTruthy: Perfect for boolean flags and UI states
Sometimes your conditions are simple truthy flags. For instance, you want to show a toast only when a modal actually opens, or connect a UI banner when a user is marked isOnline.
import { useEffectWhenTruthy } from "@okyrychenko-dev/react-effect-when";
function SessionBanner({ token, isOnline }) {
useEffectWhenTruthy(
([readyToken, online]) => {
connectBannerChannel(readyToken, online);
},
[token, isOnline],
{ once: false } // Re-run whenever it becomes truthy again!
);
}
Here, the effect runs when all dependencies resolve to a truthy value. If isOnline toggles off and then on again, the cleanup runs and the effect reconnects.
3. useEffectWhen: Full Control with a Custom Predicate
This is the most flexible option. It works well for cases like analytics events in development or effects that should run only when several conditions match.
import { useEffectWhen } from "@okyrychenko-dev/react-effect-when";
function ProductPage({ productId, isReady }) {
useEffectWhen(
([id]) => {
analytics.track("product_view", { productId: id });
},
[productId, isReady],
([, ready]) => ready === true, // Custom predicate
{ once: true }
);
}
By passing { once: true }, the effect runs once per mount lifecycle after the predicate first matches. That can reduce local Strict Mode noise for effects like analytics, while keeping the condition explicit at the call site.
You can also use custom conditions for re-synchronization. For example, syncing an item list:
useEffectWhen(
([itemList]) => {
syncToServer(itemList);
},
[items, isOnline, hasPermission],
// Custom logic right in the condition signature
([itemList, online, permission]) =>
online === true && permission === true && itemList.length > 0
);
π Bonus: Debugging with onSkip
Ever wonder why an effect isn't firing? Did a socket disconnect, or did the user drop to null? The library provides an onSkip callback for logging the dependency state that failed your predicate.
import { predicates, useEffectWhen } from "@okyrychenko-dev/react-effect-when";
useEffectWhen(
([currentUser, currentToken]) => {
initializeDashboard(currentUser, currentToken);
},
[user, token],
predicates.ready,
{
onSkip: ([pendingUser, pendingToken]) => {
console.debug("Still waiting for dependencies to load:", {
user: pendingUser,
token: pendingToken,
});
},
}
);
With this, you can inspect why an effect was skipped when the predicate does not match, instead of manually scattering debug logs through the component.
π The Takeaway
We should not have to fight React's primitives. Ad-hoc useRef(false) flags and if (!data) return conditions get messy fast.
Instead, a small abstraction over useEffect can make intent clearer, reduce repeated guard boilerplate, and preserve the guardrails that React 18 gives us in development.
This is not a replacement for plain useEffect. But if your codebase has a lot of guard-heavy effects, it can be a cleaner way to express them.
If that sounds familiar, give react-effect-when a try.
Try it out:
npm install @okyrychenko-dev/react-effect-when
If you like the approach, drop a βοΈ on the GitHub repo and let me know what you think in the comments! π
Top comments (0)