Why is switch statement used in useReducer hook to manage state?

3.2k views Asked by At

Let's take a look at the following 2 ways of using useReducer hook for state management, they both do the same thing: click add button to + 1 and click subtract button to - 1:

  1. with switch:

const myReducer = (state, action) => {
    switch (action.type) {
        case 'add':
            return {
                count: state.count + 1
            }
        case 'subtract':
            return {
                count: state.count - 1
            }
        default:
            return state
    }
}

const Reducer = () => {
    const [state, dispatch] = useReducer(myReducer, { count: 0 });

    return (
        <>
            <button onClick={() => dispatch({ type: 'add' })}>Add</button>
            <button onClick={() => dispatch({ type: 'subtract' })}>Subtract</button>
            <p>{state.count}</p>
        </>
    )
}

  1. without switch

const Reducer2 = () => {
    const [state, setState] = useReducer(
        (state, newState) => ({ ...state, ...newState }),
        { count: 0 }
    );
    
    return (
        <>
            <button onClick={() => setState({count: state.count + 1})}>Add</button>
            <button onClick={() => setState({count: state.count - 1})}>Subtract</button>
            <p>{state.count}</p>
        </>
    )

}

Which one is a better way of managing the state? I prefer 2 because it is simpler, allows us to manage state in a 'class component' way. I don't understand why 1 is needed: it needs a switch statement which is complex; if one wants to add state, a new case is needed. This all seems pretty cumbersome.

EDIT: I know this is a trivial example which has no need to use useReducer and useState is better, but what I really want to discuss is that when there are multiple states, which one is better?

1

There are 1 answers

0
Zachary Haber On BEST ANSWER

Switch statements are typically used in useReducer as a remnant from reducers in redux.

Your second example is a good way of using an approximation of this.setState in a function component, since useState is only really designed for a single value as there is no shallow merging of the old state and the new. I have extended this to one step further at the end of this answer.

As for your question of which is best to manage state in a useReducer, it really depends on what you want to use it for and how. You aren't just limited to those two types of things: you can use anything in them. I've had good luck using redux toolkit's createSlice in a useReducer for a type-safe reducer with Immer to make immutability easier.

I don't understand why 1 is needed: it needs a switch statement which is complex; if one wants to add state, a new case is needed

If you write a reducer case for each part of the state, yes. It is super cumbersome and I would definitely do it a different way. The best way to use the first approach is when you have more complicated situations you need to work with or generic ways to work with more state options.

As written in the React docs:

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. useReducer also lets you optimize performance for components that trigger deep updates because you can pass dispatch down instead of callbacks.

They are a very powerful addition to function components and allow for an easier way to work with complex logic or values that are connected logically. Whether you use it or not is of course up to you and anything that is done with useReducer can be done with useStates with varying amount of boilerplate and logic.

For a generic way to work with a lot of state properties:

const { useRef, useReducer } = React;
const dataReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      return {
        ...state,
        [action.name]: !state[action.name],
      };
    case 'change':
      return {
        ...state,
        [action.name]: action.value,
      };
    default:
      return state;
  }
};
function Example() {
  const [data, dispatch] = useReducer(dataReducer, {
    check1: false,
    check2: false,
    check3: false,
    input1: '',
    input2: '',
    input3: '',
  });
  const throwErrorRef = useRef(null);
  const handleChange = function (e) {
    const { name, value } = e.currentTarget;
    dispatch({ type: 'change', name, value });
  };
  const handleToggle = function (e) {
    const { name } = e.currentTarget;
    dispatch({ type: 'toggle', name });
  };
  const checkBoxes = ['check1', 'check2', 'check3'];
  const inputs = ['input1', 'input2', 'input3'];
  return (
    <div>
      {checkBoxes.map((name) => (
        <label>
          {name}
          <input
            type="checkbox"
            name={name}
            onChange={handleToggle}
            checked={data[name]}
          />
        </label>
      ))}
      <br />
      {inputs.map((name) => (
        <label>
          {name}
          <input
            type="text"
            name={name}
            onChange={handleChange}
            value={data[name]}
          />
        </label>
      ))}
    </div>
  );
}

ReactDOM.render(<Example />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"/>

As for slightly more complex logic, here's an example for a data fetch:

const { useRef, useReducer } = React;
const dataReducer = (state, action) => {
  switch (action.type) {
    case 'fetchStart':
      return {
        loading: true,
        data: null,
        error: null,
      };
    case 'fetchError':
      if (!state.loading) {
        return state;
      }
      return {
        loading: false,
        data: null,
        error: action.payload.error,
      };
    case 'fetchSuccess': {
      if (!state.loading) {
        return state;
      }
      return {
        loading: false,
        data: action.payload.data,
        error: null,
      };
    }
    default:
      return state;
  }
};
function Example() {
  const [{ loading, data, error }, dispatch] = useReducer(dataReducer, {
    loading: false,
    data: null,
    error: null,
  });
  const throwErrorRef = useRef(null);
  const handleFetch = function () {
    if (loading) {
      return;
    }
    dispatch({ type: 'fetchStart' });
    const timeoutId = setTimeout(() => {
      dispatch({ type: 'fetchSuccess', payload: { data: { test: 'Text' } } });
    }, 5000);
    throwErrorRef.current = () => {
      clearTimeout(timeoutId);
      dispatch({ type: 'fetchError', payload: { error: 'Oh noes!' } });
    };
  };
  const handleFetchError = function () {
    throwErrorRef.current && throwErrorRef.current();
  };
  return (
    <div>
      <button onClick={handleFetch}>Start Loading</button>
      <button onClick={handleFetchError}>Throw an error in the fetch!</button>
      <div>loading: {`${loading}`}</div>
      <div>error: {error}</div>
      <div>data: {JSON.stringify(data)}</div>
    </div>
  );
}

ReactDOM.render(<Example />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"/>

A simple one I've had use of is a force update that just increments a value to cause the component to re-render.

const [,forceUpdate] = useReducer((state)=>state+1,0);
// Example use: forceUpdate();

I modified your example 2 to add support for the function method of updating the state so it's closer to a full setState knockoff using useReducer. I can't think of a decent way to make the callback work (the second parameter in this.setState)

const { useRef, useReducer } = React;
const stateReducer = (state, action) => {
  if (typeof action === 'function') {
    action = action(state);
  }
  return { ...state, ...action };
};
const useMergeState = (initialState) => {
  return useReducer(stateReducer, initialState);
};
function Example() {
  const [state, setState] = useMergeState({
    loading: false,
    data: null,
    error: null,
    count: 0,
  });
  const throwErrorRef = useRef(null);
  const handleFetch = function () {
    if (state.loading) {
      return;
    }
    setState({ loading: true });
    const timeoutId = setTimeout(() => {
      setState({
        data: { text: 'A super long text', loading: false, error: null },
      });
    }, 5000);
    throwErrorRef.current = () => {
      clearTimeout(timeoutId);
      setState({ error: 'Oh noes!', loading: false, data: null });
    };
  };
  const handleFetchError = function () {
    throwErrorRef.current && throwErrorRef.current();
  };
  const incrementCount = function () {
    setState((state) => ({ count: state.count + 1 }));
    setState((state) => ({ count: state.count + 1 }));
  };
  return (
    <div>
      <button onClick={handleFetch}>Start Loading</button>
      <button onClick={handleFetchError}>Throw an error in the fetch!</button>
      <div>loading: {`${state.loading}`}</div>
      <div>error: {state.error}</div>
      <div>data: {JSON.stringify(state.data)}</div>
      <button onClick={incrementCount}>increase count by 2</button>
      <div>count: {state.count}</div>
    </div>
  );
}

ReactDOM.render(<Example />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"/>