DEV Community

Cover image for React.js ~Tips for making UI that is reusable and testable~
Ogasawara Kakeru
Ogasawara Kakeru

Posted on

React.js ~Tips for making UI that is reusable and testable~

In the previous article, I wrote about a single responsibility of HTML and CSS.
In this article, I'm goint to write about State Reducer Pattern.

Important thing to consider before adding new features.

Let's consider State Reducer Pattern before adding new features such as if statemant, parameter or return value to existing function

This might be unknown pattern. This pattern extends Inversion of Control Pattern that is expanded by Martin Fowler who is known as an author of Refactoring Book. Inversion of Control pattern is also known as “control inversion” or the Dependency Injection pattern.

State Reducer Pattern is invented by Kent C.Dodds who is also known as a creator of React Testing Library.

If you add a argument, option and conditional statement, you add new ones over and over again every time you implement them.

It doesn't matter once. However, when multiple time added by other developers, this component gets more complex.

This is State Reducer Pattern to prevent this case.

Here is a specific example of a use case.
We need to add new functionality to a count button on a page.
The existing count button works as follows:
When the plus button is clicked, the count increases by 1, up to a maximum of 5.
When the minus button is clicked, the count decreases by 1, down to a minimum of 0.
The new functionality we want to add is as follows:
When the plus button is clicked, the count increases by 1, up to a maximum of 10.
When the minus button is clicked, the count decreases by 2, down to a minimum of 0.
In a typical scenario, you would add arguments or conditional branching to useCounter() to define the maximum number of clicks allowed and the amount by which the count decreases with each click.

const useCounter = () => {
 const [state, dispatch] = useReducer(reducer, initialState);
   function reducer(state, action) {
     switch (action.type) {
       case "increment":
         return {
           count: Math.min(state.count + 1, 5)
         };
       case "decrement":
         return {
           count: Math.max(0, state.count - 1)
         };
       // Add new use case   
       case "decrement-two": 
         return {
           count: Math.max(0, state.count - 2)
         };
       default:
         throw new Error();
     }
   }

   const handleIncrementClick = () => {
     dispatch({ type: "increment" });
   };

   const handleDecrementClick = () => {
     dispatch({ type: "decrement" });
   };

   // Add a function for the new case   
   const handleDecrementTwoClick = () => {
     dispatch({ type: "decrement-two" });
   };

   return {
     count,
     handleIncrementClick,
     handleDecrementClick,
     handleDecrementTwoClick, // Add a return value to use in UI component
   }
}

Enter fullscreen mode Exit fullscreen mode

However, the State Reducer pattern does not add logic to useCounter(); instead, it defines a reducer (logic) for a new use case within the Usage component (the child component that uses useCounter()) and passes it to useCounter() as an argument.
This allows you to add new functionality without affecting existing features or useCounter() itself.


const Usage = (): JSX.Element => {
  const reducer = (state: typeof initialState, action: ACTIONTYPE) => {
    switch (action.type) {
      case "decrement":
        return {
          count: Math.max(0, state.count - 2) // Process that subtracts 2 each time the minus button is clicked (default: process that subtracts 1 each time the minus button is clicked)
        };
      default:
        return useCounter.reducer(state, action);
    }
  };

  const { count, handleDecrementClick, handleIncrementClick } = useCounter({
    state: { initial: 0, max: 10 },
    reducer
  });

  return (....) // Save

Enter fullscreen mode Exit fullscreen mode
export const useCounter = ({
  state,
  // Modify existinf reducer to internalReducer
  reducer = internalReducer 
}: useCounterProps): useCounterReturnType => {
  const [{ count }, dispatch] = useReducer(reducer, { count: state.initial });

  const handleIncrementClick = () => {
    dispatch({ type: "increment", payload: { max: state.max } });
  };

  const handleDecrementClick = () => {
    dispatch({ type: "decrement" });
  };

  return {
    count,
    handleIncrementClick,
    handleDecrementClick
  };
};

useCounter.reducer = internalReducer;

Enter fullscreen mode Exit fullscreen mode

If you find yourself adding conditional branching—such as if or switch statements—to existing functions, consider reversing the control flow by moving the logic from the parent component to the child component, rather than having the parent component use the logic in the child component.

Top comments (0)