prod-ready-react
typescript
react
tdd
testing
vitest
react-testing-library
react-hooks
custom-react-library

This article is part of the Prod Ready React series. You can track the entire progress and access the production-ready codebase in this npm library and GitHub repository. I've structured the code so you can either install it directly or copy and paste it into your repository, including both tests and implementation, similar to the ShadCN library.

(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(false);
  };

  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.

import { useSimpleFeature } from '@greenonsoftware/react-kit';

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 versions is better than just one).

import { useFeature } from '@greenonsoftware/react-kit';

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>
  );
};

With that in mind, I’m excited to introduce a new article series where we’ll explore implementing React hooks, generic utilities, and publishing them as a library—all powered by the React, Nx, Vitest, React-Testing-Library and advanced TypeScript.

Ready? Let’s dive in—enjoy the first article!

What You'll Learn Today?

  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
  6. Testing with React Testing Library + Vitest
  7. Test Driven Development concepts

Why Two Hooks Instead of One?

When managing UI visibility with associated data, you risk unsafe behavior. A typical approach looks like this:

const [data, setData] = useState<User | null>(null);
const [isOpen, setIsOpen] = useState(false);

if (isOpen) {
   // It may be null!
   console.log(data.id);
}

To prevent runtime errors, you must always check data:

if (isOpen && data) {
   console.log(data.id);
}

This increases cyclomatic complexity. A better approach is the discriminant property technique.

type State = 
   { is: "off" } | 
   { is: "on"; data: { id: number } };

Now, you will have single if statement.

if (state.is === "on") {
   console.log(state.data); // Safe!
}

state.data; // TypeScript error

This works well 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, toggle() doesn’t work when data is involved...

open({ id: 0 });
close();
toggle(); // What should data be?

That’s why toggle is available in useSimpleFeature, but in the data-driven version, useFeature, toggling is not possible.

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 or configurations

Implementation of useSimpleFeature Hook

Let's start with the tests and behaviors we want to verify for the useSimpleFeature hook (since it's the simpler one to implement 😃).

import { renderHook, act } from '@testing-library/react';
import { useSimpleFeature } from './use-simple-feature';

describe(useSimpleFeature.name, () => {
  it('is off by default', () => {
    const { result } = renderHook(() => useSimpleFeature());
    expect(result.current.isOn).toBe(false);
    expect(result.current.isOff).toBe(true);
  });

  it('assigns passed visibility value as initial', () => {
    const { result } = renderHook(() => useSimpleFeature(true));
    expect(result.current.isOn).toBe(true);
    expect(result.current.isOff).toBe(false);
  });

  it('assigns passed visibility function as initial', () => {
    const { result } = renderHook(() => useSimpleFeature(() => true));
    expect(result.current.isOn).toBe(true);
    expect(result.current.isOff).toBe(false);

    act(() => {
      result.current.off();
    });

    expect(result.current.isOn).toBe(false);
    expect(result.current.isOff).toBe(true);

    act(() => {
      result.current.reset();
    });

    expect(result.current.isOn).toBe(true);
    expect(result.current.isOff).toBe(false);
  });

  it('turns on the feature', () => {
    const { result } = renderHook(() => useSimpleFeature());
    act(() => {
      result.current.on();
    });
    expect(result.current.isOn).toBe(true);
    expect(result.current.isOff).toBe(false);
  });

  it('turns off the feature', () => {
    const { result } = renderHook(() => useSimpleFeature(true));
    act(() => {
      result.current.off();
    });
    expect(result.current.isOn).toBe(false);
    expect(result.current.isOff).toBe(true);
  });

  it('toggles the feature', () => {
    const { result } = renderHook(() => useSimpleFeature());
    act(() => {
      result.current.toggle();
    });
    expect(result.current.isOn).toBe(true);
    act(() => {
      result.current.toggle();
    });
    expect(result.current.isOn).toBe(false);
  });

  it('resets to the initial visibility', () => {
    const { result } = renderHook(() => useSimpleFeature(true));
    act(() => {
      result.current.off();
    });
    expect(result.current.isOn).toBe(false);
    act(() => {
      result.current.reset();
    });
    expect(result.current.isOn).toBe(true);
  });

  it('allows to override', () => {
    const { result } = renderHook(() => useSimpleFeature());
    act(() => {
      result.current.set(true);
    });
    expect(result.current.isOn).toBe(true);
  });
});

I used ${useSimpleFeature.name} as the describe block title. While this isn't always recommended, in a library codebase like this, it's the best option to immediately identify which module is failing. Creating complex, user-oriented test descriptions doesn't make sense in such a generic context. This approach also eliminates the need to manually update the describe block when renaming or removing the function—TypeScript will catch it for us.

Now, the tests are failing because the hook hasn’t been implemented yet. Let's add the implementation.

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]
  );
};

export { useSimpleFeature };

We've written more test code than actual implementation, which is typical 😃. 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 additional useState prevents this issue.

  3. useMemo is used instead of multiple useCallback hooks to avoid redundancy.

  4. I didn’t include initState in the useMemo dependency array because it’s only used for the initial render and never changes. Adding it would trigger unnecessary recalculations internally by React.

  5. Instead of useMemo, I used useState to store the initial state. This simple trick removes the need for a dependency array while achieving the same effect with cleaner syntax.

And that’s it—the first version of hook is ready!

You can check out the current progress of the entire library and the latest state of this code in the GitHub repo.

Implementation of useFeature Hook

This one will be a little harder, but the idea remains the same. 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 use TDD and start with the tests.

import { renderHook, act } from '@testing-library/react';
import { useFeature } from './use-feature';

describe(useFeature.name, () => {
  it('allows to set data', () => {
    type User = { id: number };
    const { result } = renderHook(() => useFeature<User>());

    expect(result.current).toEqual(expect.objectContaining({ is: 'off' }));

    act(() => {
      result.current.on({ id: 0 });
    });

    expect(result.current).toEqual(
      expect.objectContaining({ is: 'on', data: { id: 0 } })
    );
  });

  it('sets initial visibility from calculation result', () => {
    const { result } = renderHook(() => useFeature(() => ({ is: `off` })));

    expect(result.current).toEqual(expect.objectContaining({ is: 'off' }));
  });

  it('is off by default', () => {
    const { result } = renderHook(() => useFeature());
    expect(result.current).toEqual(expect.objectContaining({ is: 'off' }));
  });

  it('resets to initial state', () => {
    const { result } = renderHook(() => useFeature({ is: 'on', data: 42 }));

    expect(result.current).toEqual(
      expect.objectContaining({ is: 'on', data: 42 })
    );

    act(() => {
      result.current.off();
    });

    expect(result.current).toEqual(expect.objectContaining({ is: 'off' }));

    act(() => {
      result.current.reset();
    });

    expect(result.current).toEqual(
      expect.objectContaining({ is: 'on', data: 42 })
    );
  });

  it('turns on the feature', () => {
    const { result } = renderHook(() => useFeature());

    act(() => {
      result.current.on(100);
    });

    expect(result.current).toEqual(
      expect.objectContaining({ is: 'on', data: 100 })
    );
  });

  it('turns off the feature', () => {
    const { result } = renderHook(() => useFeature({ is: 'on', data: 42 }));

    act(() => {
      result.current.off();
    });

    expect(result.current).toEqual(expect.objectContaining({ is: 'off' }));
  });

  it('allows to override', () => {
    const { result } = renderHook(() => useFeature());

    act(() => {
      result.current.set({ is: 'on', data: 'hello' });
    });

    expect(result.current).toEqual(
      expect.objectContaining({ is: 'on', data: 'hello' })
    );
  });
});

The implemented tests are quite similar, but instead of testing a boolean flag, we're verifying the objects returned from the hook.

Note that we use expect.objectContaining to ensure the expected changes have been applied. Since the useFeature hook returns methods within the same object for managing the feature, explicitly checking their existence is unnecessary—we validate them indirectly by calling them in separate tests.

Now, it's time to implement the hook itself.

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]
  );
};

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 (separating updates from reads) typically enhances performance and is widely used in React (e.g., the useState hook, which separates the value from the setter). However, in this case, enforcing it would introduce unnecessary complexity for the consumer.

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 property name 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!

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.

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

Links and Materials

  1. All articles from the Prod Ready React series
  2. The @greenonsoftware/react-kit npm library
  3. The GitHub repository with up-to-date code
  4. The naming convention I use in tests
  5. Learn more about TypeScript generics conventions
Author avatar
About Authorgreenonsoftware

We are a company dedicated to providing mentorship, education, and consultation in web technologies such as React, Cypress, Node, AWS, and more. Visit our website or contact us for more info