prod-ready-react
typescript
react
react-hooks

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:

  1. TypeScript generics and the discriminant property technique
  2. Creating reusable custom hooks
  3. Memoization: when and how to use it
  4. State initialization and reset techniques
  5. 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:

  1. Safety & predictability
  2. Better TypeScript feedback
  3. 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.

  1. type Setter<TState> = TState | (() => TState) allows passing both a value and an initializer function, similar to React.useState(setter). This enables usage like useSimpleFeature(fn) instead of:

    const [result] = useState(complexFnToCalculate);
    useSimpleFeature(result);
    
  2. The hook uses two useState calls. The first one stores the initial state to ensure reset behaves correctly. If the initial value were derived directly from props, 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 useState prevents this issue.

  3. 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 ButtonPanel component that we've optimized with React.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 useSimpleFeature hook did not use useMemo, 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 App component re-renders, useSimpleFeatureWithoutMemo creates a new modal object, and you'll see "ButtonPanel is re-rendering!" in the console. This happens because React.memo does a shallow comparison, and the new modal object is a different reference, causing ButtonPanel to re-render even though its actual content hasn't changed.

    By wrapping our return object in useMemo, we solve this problem. The modal object will now only be re-created when isOn changes. This is due to the fact that initState always has a stable reference. As a result, our ButtonPanel will 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.

  4. You'll notice initState is in the useMemo dependency array. Even though it never changes after the first render, it's good practice to include it because our reset function uses it. This keeps the React linter happy and prevents any potential bugs with stale values.

  5. 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 useMemo is 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:

The useFeature hook demo

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:

  1. Handles a simple on/off state if no generic type is provided.
  2. Handles an on/off state with data if a generic type is provided.
  3. Provides a toggle function 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' or is: 'off', it uses a boolean discriminant property: isOn: true or isOn: false. This also means we lose the convenient isOff property that useSimpleFeature provided, 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 used useRef because 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 toggle function now uses setState with a callback function instead of directly accessing state. This avoids stale closure issues and ensures we always work with the current state. The callback receives currentState and 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 calling on() without arguments when TData is undefined. This means you can write toggle.on() instead of toggle.on(undefined) for simple boolean toggles.
  • Stateful Toggle Behavior: The toggle function's behavior depends on whether on() 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.

useToggle hook demo

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:

Hooks demo in real app

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

  1. All articles from the Prod Ready React series
  2. Learn more about TypeScript generics conventions
Author avatar
About Authorpolubinski.dev

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 🌋🤝