This article is part of the Prod Ready React series.
(1) Prod Ready React: useFeature and useSimpleFeature hooks
Feature toggling is one of the most repetitive behavior you’ll encounter in frontend codebases. By toggling, I mean showing or hiding UI elements based on interactions or specific conditions. Thanks to React, it’s relatively simple to implement (much easier than in jQuery or Vanilla JavaScript), but it can still feel like boilerplate—imagine repeating this code in 20 places.
const UserView = () => {
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);
};
const toggle = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const close = () => {
setIsOpen(false);
};
return (
<>
<button onClick={toggle}>Toggle</button>
<button onClick={open}>Open</button>
{isOpen && <Modal onClose={close} />}
</>
);
};
We can greatly reduce that by eliminating repetition through a custom hook.
const MyComponent = (props: { flag: boolean }) => {
// Simple use case (by default disabled)
const modal = useSimpleFeature();
// or with initial state
const modal = useSimpleFeature(true);
// or with the result as complex initial calculations
const modal = useSimpleFeature(calculateFlag);
// or based on the properties from component
const modal = useSimpleFeature(props.flag);
const modal = useSimpleFeature(() => props.flag);
return (
<div>
<p>Status: {modal.isOff ? 'Off' : 'On'}</p>
<p>Status: {modal.isOn ? 'On' : 'Off'}</p>
<button onClick={modal.toggle}>Toggle</button>
<button onClick={modal.on}>Turn On</button>
<button onClick={modal.off}>Turn Off</button>
<button onClick={modal.reset}>Reset</button>
</div>
);
};
It ensures consistent naming by avoiding common variations like isOpen, opened, open, and visible. Additionally, it allows easy state reset to the initial value (set on the first render) and includes built-in memoization.
In some cases, just knowing whether something is visible isn’t enough; you may also need additional data to populate the content. For example, in a user details modal, clicking a user ID should display their information. For this, there’s a second version of the hook—I’ll explain later why having both is better, in my opinion, instead of just one.
type UserConfig = { id: number };
const MyComponent = () => {
const feature = useFeature<UserConfig>();
const feature = useFeature();
// or with initial state
const feature = useFeature({ is: 'on', data: 42 });
const feature = useFeature(({ is: 'off' }));
const feature = useFeature(() => ({ is: 'off' }));
const feature = useFeature(complexFnThatDeterminesResult);
if (feature.is === `on`) {
// Data is only available when feature is "on"
console.log(feature.data);
}
return (
<div>
<p>Accessing data not allowed without proper check 💢: {feature.data}</p>
<p>Feature state: {feature.is}</p>
<button onClick={() => feature.on(100)}>Turn On with Data</button>
<button onClick={feature.off}>Turn Off</button>
<button onClick={feature.reset}>Reset</button>
</div>
);
};
Let's explore the key concepts in this implementation:
- TypeScript generics and the discriminant property technique
- Creating reusable custom hooks
- Memoization: when and how to use it
- State initialization and reset techniques
- Clean API design
Why Two Hooks Instead of One?
It's important to understand that we're not just creating a simple wrapper for a boolean flag. The real goal is to make the entire process of managing state with associated data type-safe. By using the discriminant property technique, we directly reduce the cyclomatic complexity in the consumer's code. This approach guarantees that you can only access the data property when it's truly defined—that is, when the feature is on, eliminating entire classes of runtime errors.
A typical, unsafe approach looks like this:
const [data, setData] = useState<User | null>(null);
const [isOpen, setIsOpen] = useState(false);
if (isOpen) {
// It may be null! This can cause a runtime crash.
console.log(data.id);
}
To prevent this, you must always add extra checks:
if (isOpen && data) {
console.log(data.id);
}
This is exactly the kind of complexity we want to remove. A better approach is the discriminant property technique:
type State =
{ is: "off" } |
{ is: "on"; data: { id: number } };
Now, you have a single, safe check:
if (state.is === "on") {
console.log(state.data); // Safe! TypeScript knows data exists here.
}
state.data; // TypeScript error: Property 'data' does not exist on type 'State'.
This works perfectly when data is required, but many cases only involve toggling visibility. That’s why I introduced a simpler hook:
const modal = useSimpleFeature(); // For basic toggling
const modalWithData = useFeature(); // When data is needed
Additionally, a toggle() function for a feature with data gets tricky. Toggling from on to off is easy. But what about toggling from off back to on? The hook would need data, but a simple toggle function doesn't take any arguments.
One idea is to make the hook "remember" the last data it had. But this can lead to confusing behavior:
// 1. Turn on the feature with user A's data
feature.on({ id: 'A' });
// 2. Turn it off
feature.off();
// 3. Toggle it back on
feature.toggle(); // Now the feature is on with user A's data again.
// What if we change the context?
feature.on({ id: 'B' }); // Now the hook "remembers" user B
feature.off();
feature.toggle(); // Now it turns on with user B's data!
Suddenly, the result of toggle() depends on what happened before, which isn't obvious. This can easily cause bugs that are hard to track down. To keep things clear and predictable, useFeature doesn't have a toggle function. You always have to be explicit and provide data when turning it on with on(data).
So, the only downside of having both hooks is minor duplication, but it is negligible compared to the benefits:
- Safety & predictability
- Better TypeScript feedback
- Lower complexity – no extra runtime checks, no complex TypeScript/configurations
Implementation of useSimpleFeature Hook
Let's begin with the implementation of useSimpleFeature, a hook for managing simple on/off states.
import { useMemo, useState } from 'react';
type Setter<TState> = TState | (() => TState);
const useSimpleFeature = (defaultState: Setter<boolean> = false) => {
const [initState] = useState(defaultState);
const [isOn, setIsOn] = useState(initState);
return useMemo(
() => ({
isOff: !isOn,
isOn,
set: setIsOn,
on: () => setIsOn(true),
off: () => setIsOn(false),
toggle: () => setIsOn((prev) => !prev),
reset: () => setIsOn(initState),
}),
[isOn, initState]
);
};
export { useSimpleFeature };
Now, let's go through some key points.
-
type Setter<TState> = TState | (() => TState)allows passing both a value and an initializer function, similar toReact.useState(setter). This enables usage likeuseSimpleFeature(fn)instead of:const [result] = useState(complexFnToCalculate); useSimpleFeature(result); -
The hook uses two
useStatecalls. The first one stores the initial state to ensureresetbehaves correctly. If the initial value were derived directly fromprops, resetting could lead to unexpected behavior:useSimpleFeature(props.flag); // flag is "true" // Re-renders and flag is "false" feature.reset(); // should be "true", but it's "false"Storing the initial value in
useStateprevents this issue. -
Why bother with
useMemo? At first glance, it might seem like overkill. But this is a key part of making the hook efficient and easy to use. Without it, our hook would return a brand new object on every single render. This can cause unnecessary re-renders in components that use our hook.Let's see it in action. Imagine a
ButtonPanelcomponent that we've optimized withReact.memo:const ButtonPanel = React.memo(({ modal }) => { console.log("ButtonPanel is re-rendering!"); return ( <div> <button onClick={modal.on}>Turn On</button> <button onClick={modal.off}>Turn Off</button> </div> ); });Now, if our
useSimpleFeaturehook did not useuseMemo, here's what would happen:const App = () => { const modal = useSimpleFeatureWithoutMemo(); // Creates a new object every time const [count, setCount] = useState(0); return ( <div> {/* This button has nothing to do with the modal state */} <button onClick={() => setCount(c => c + 1)}> Re-render App ({count}) </button> <p>Modal is {modal.isOn ? 'On' : 'Off'}</p> <ButtonPanel modal={modal} /> </div> ); };Every time you click the "Re-render App" button, the
Appcomponent re-renders,useSimpleFeatureWithoutMemocreates a newmodalobject, and you'll see"ButtonPanel is re-rendering!"in the console. This happens becauseReact.memodoes a shallow comparison, and the newmodalobject is a different reference, causingButtonPanelto re-render even though its actual content hasn't changed.By wrapping our return object in
useMemo, we solve this problem. Themodalobject will now only be re-created whenisOnchanges. This is due to the fact thatinitStatealways has a stable reference. As a result, ourButtonPanelwill no longer re-render needlessly.This is even more critical when using the hook with Context:
const FeatureProvider = ({ children }) => { const modal = useSimpleFeatureWithoutMemo(); // The `value` prop gets a new object on every render, // causing all consumers to re-render ;/ return ( <FeatureContext.Provider value={modal}> {children} </FeatureContext.Provider> ); }Using our memoized version of the hook fixes this issue right away, providing a stable object.
-
You'll notice
initStateis in theuseMemodependency array. Even though it never changes after the first render, it's good practice to include it because ourresetfunction uses it. This keeps the React linter happy and prevents any potential bugs with stale values. -
You might hear that the new React Compiler will make manual memoization like this unnecessary. That's true! But when you're writing a hook for others to use, you can't be sure they have the compiler turned on. So, for now, adding
useMemois the safe bet to give everyone good performance out of the box.
And that’s it—the first version of hook is ready.
Implementation of useFeature Hook
This hook is a bit more complex, as it manages state that includes associated data. The contract we return will be slightly different. Instead of flags, we'll have an object that uses a discriminant property, and we won’t implement toggle for the reasons mentioned at the beginning of the article.
Let's dive into its implementation.
import { useState, useMemo } from 'react';
type Setter<TState> = TState | (() => TState);
type FeatureOnState<TData> = { is: 'on'; data: TData };
type FeatureOffState = { is: 'off' };
type FeatureState<TData> = FeatureOnState<TData> | FeatureOffState;
const useFeature = <TData>(
defaultState: Setter<FeatureState<TData>> = { is: 'off' }
) => {
const [initState] = useState(defaultState);
const [state, setState] = useState(initState);
return useMemo(
() => ({
...state,
on: (data: TData) => setState({ is: 'on', data }),
set: setState,
off: () => setState({ is: 'off' }),
reset: () => setState(initState),
}),
[state, initState]
);
};
export { useFeature };
First thing worth mentioning is the merging of the state inside the useMemo block. Instead of separating values for reading and methods for updating, we're keeping everything inside a single object. The reason for this is to reduce boilerplate on the consumer side.
Take a look at the example below, which illustrates why:
const [userManagement, userManagementAction] = useFeature();
// vs
const usersManagement = useFeature();
The Command Query Separation Principle is a good pattern, but forcing it here would just make the hook harder to use. Keeping everything together provides a much nicer developer experience.
The next and last important aspect is passing the generic TData to the hook and then through FeatureState<TData>. This generic utility type creates a union with the same is property but different values. Thanks to this, TypeScript ensures that the data property is only accessible when the current state is on.
Here’s a demo of how it works in the IDE:
And that's it—the second hook is ready!
Unifying the Hooks with useToggle: Is It Worth It?
We've established that having two separate hooks is a clean solution. But what if we tried to combine them? Is it possible to create a single, powerful hook that handles both cases? Let's explore this and call our new hook useToggle.
The goal is to create a hook that:
- Handles a simple on/off state if no generic type is provided.
- Handles an on/off state with data if a generic type is provided.
- Provides a
togglefunction in both scenarios.
Here's how we could implement it:
import { useState, useMemo, useRef, useCallback } from "react";
// Define the state shapes
type ToggleOnState<TData> = TData extends undefined
? { isOn: true }
: { isOn: true; data: TData };
type ToggleOffState = { isOn: false };
type ToggleState<TData> = ToggleOnState<TData> | ToggleOffState;
// Define an initial state setter
type InitialState<TData> = ToggleState<TData> | (() => ToggleState<TData>);
export const useToggle = <TData = undefined>(
defaultState: InitialState<TData> = {
isOn: false,
} as InitialState<TData>,
) => {
const [initState] = useState(defaultState);
const [state, setState] = useState(initState);
// Persist data across toggles to enable smart toggle() behavior
const lastData = useRef<TData | undefined>(
"data" in initState ? initState.data : undefined,
);
// For data equal to "undefined", accepts no arguments for better ergonomics
const on = useCallback(
(...args: TData extends undefined ? [] | [undefined] : [TData]) => {
const data = args[0] as TData;
lastData.current = data;
const newState = { isOn: true, data } as ToggleState<TData>;
setState(newState);
},
[],
);
const off = useCallback(() => {
setState({ isOn: false });
}, []);
// Uses setState callback to avoid stale closures
const toggle = useCallback(() => {
setState((currentState) => {
if (currentState.isOn) {
return { isOn: false };
}
if (lastData.current !== undefined) {
return { isOn: true, data: lastData.current } as ToggleState<TData>;
}
return { isOn: true } as ToggleState<TData>;
});
}, []);
const reset = useCallback(() => {
setState(initState);
}, [initState]);
// Stable reference prevents unnecessary re-renders
// when "memo" used or "React Context API provider value"
return useMemo(
() => ({
...state,
on,
off,
toggle,
reset,
}),
[state, on, off, toggle, reset],
);
};
This unified hook works, but let's look at the trade-offs:
- Different API Contract: The state shape is now simplified. Instead of a string
is: 'on'oris: 'off', it uses a boolean discriminant property:isOn: trueorisOn: false. This also means we lose the convenientisOffproperty thatuseSimpleFeatureprovided, forcing the consumer to calculate it (!toggle.isOn). - Internal Complexity: We still need a
useRef(lastData) to store the "memory" for the toggle function. We've useduseRefbecause it guarantees an up-to-date value during re-rendering; it's synchronous, doesn't create re-renders, and doesn't need to be included in the dependency array. - Improved Toggle Implementation: The
togglefunction now usessetStatewith a callback function instead of directly accessing state. This avoids stale closure issues and ensures we always work with the current state. The callback receivescurrentStateand makes decisions based on that fresh value, making the toggle behavior more reliable. - Better Ergonomics for Simple Cases: The
on()function uses conditional types (...args: TData extends undefined ? [] | [undefined] : [TData]) to allow callingon()without arguments whenTDataisundefined. This means you can writetoggle.on()instead oftoggle.on(undefined)for simple boolean toggles. - Stateful Toggle Behavior: The
togglefunction's behavior depends on whetheron()was called before with data. This hidden dependency makes the hook less predictable—toggling back "on" will restore the last data used, which might not always be obvious to the person using it. - TypeScript Gymnastics: The types are more complicated than either of the individual hooks, using conditional types to correctly shape both the state and the function signatures.
While it's cool that we can create a single hook, this experiment highlights why the original two-hook approach is often better. By separating concerns, useSimpleFeature and useFeature provide simple, predictable, and self-documenting APIs. The unified useToggle is more powerful but at the cost of clarity and predictability.
Here is the demo of how it works in an IDE (quite cool, anyway); however, I prefer the version with two separate ones.
FAQ
Q1: Isn't that overengineering?
It may look like it; however, if you're facing 100 different show/hide code places, it will reduce the boilerplate dramatically and provide consistent, predictable memoization and naming. There will no longer be isOn, on, visible, hidden, or n other names. I just like standardization in code and the option to import and use things instead of repeating myself, especially on a large scale.
Plus, what is most important to me is that it out-of-the-box protects against runtime exceptions when data is undefined and you're trying to access it.
Last small addition is the nice modularization of code. Instead of having n repeated useState calls, you have one wrapping object that tells everything and doesn't mix with other properties used inside the component's body -> modal.isOn, instead of isModalOn, etc.
Q2: Why have you used this weird trick with a type union?
I'm describing it better in the Exhaustiveness Checking And Discriminant Property: The Complete Guide article. To keep it short, it just blocks the option to access the data if something is not enabled. This removes the risk of accessing smth.data when the data is not there.
Q3: Why not just use a simple data !== null check to show something and the data inside?
The answer is simple. Imagine a modal that you're showing with this condition: if you change the data, it will automatically close. Sometimes, you want to retain the older data for a while, change it, wait some time, and then close the modal. With the condition of data !== null, this isn't possible 😺; it just automatically closes. That's why we've added custom metadata to indicate whether something is displayed.
Summary
We have implemented two concise hooks to provide a simple and efficient way to display and hide dedicated UI elements.
The first, useSimpleFeature, is designed for simple cases where the goal is just to show and hide with minimal code.
The second, useFeature, can handle more complex scenarios where certain data needs to be attached when enabling a feature. Both hooks offer consistent naming, an intuitive API, and performance optimizations to minimize unnecessary re-renders—particularly when other components receive these values as props and use memo or ContextAPI.
We also explored creating a unified useToggle hook. While it's possible to combine both functionalities, doing so introduces internal complexity and a less predictable API, especially around the toggle function. This experiment reinforces the idea that sometimes, two simple, specialized tools are better and easier to work with than one complex, "do-it-all" solution.
I use these hooks extensively in my apps. As you can see, they are almost everywhere. The number of lines saved across the entire project is quite significant—around 10 lines per usage compared to a custom, from-scratch solution.
Here is the scale:
So, after reading this article, you've got 3 hooks from which you can pick the one you like—modify it according to your code style and preferred implementation. All of them may be greatly used to reduce boilerplate and manual memoization or other Reactish crap :D
Links and Materials
Currently engaged in mentoring, sharing insights through posts, and working on a variety of full-stack development projects. Focused on helping others grow while continuing to build and ship practical solutions across the tech stack. Visit my Linkedin or my site for more 🌋🤝