The right way to use the Hook useReducer for a complex state
Asked Answered
P

4

6

Trying to catch up on the React Hooks. I'm reading that they recommend the use of the Hook useReducer when dealing with a complex state. But my doubt starts from the following scheme:

Using React + Typescript, suppose I have a state with several fields (I'll give an example with classes):

type Person = {
   username: string,
   email: string
}

type Pet = {
   name: string,
   age: number
}

this.state: MyState = {
    person: Person,
    pet: Pet,
    loading: boolean
}

If I wanted to handle this state with a new Hooks-based approach, I could think of several options:

Option 1: using a Hook useState for each field

const [person, setPerson] = useState<Person>(null)
const [pet, setPet] = useState<Pet>(null)
const [loading, setLoading] = useState<boolean>(false)

This method has the disadvantage of low scalability and some of my real states have at least 15 fields, is unmanageable.

Option 2: Using a single setState for the entire object

const [state, setState] = useState<MyState>({
    person: null,
    pet: null,
    loading: false
})

This is the method that seems simplest to me, where I can simply do setState((prev) => {...prev, person: {username: 'New', email: '[email protected]'}}) or adapt it to any field modification. I can even update several fields at once.

Option 3: use a useReducer for each of the complex fields by passing a specific reducer for each one, use useState for the simple ones

const [person, dispatchPerson] = useReducer<Person>(personReducer)
const [pet, dispatchPet] = useReducer<Pet>(petReducer)
const [loading, setLoading] = useState<boolean>(false)

I find this one manageable, but I don't see the point of having to set up a reduce function with a multi-line switch, in addition to the tedious process of setting the dispatching types in Typescript for each reduce function when you could just use setState and be done with it.

Option 4: use one useReducer for the entire state

const [state, dispatch] = useReducer(generalReducer)

The main problem with this is the type of the reducer, think of 15 fields, where all the types and the structure of the information to update them are different. Specifying the types in Typescript does not scale or is unclear. There are several articles about this and none of them solve the problem in a clean way (example 1), or they are extremely simple and don't apply to the problem (example 2).

What would be the best way to handle this type of cases? Where the number of fields in the state is large and can have several levels of depth. Are there good practices or any official examples that represent these cases? The examples with a number field to handle a simple counter that bloggers or official documentation people like so much are not being very helpful.

Any light on the subject would be more than welcome! Thanks in advance and sorry about my English

Pegues answered 30/4, 2020 at 19:16 Comment(4)
For state that you want to get/set from anywhere in your App, the Redux strategy seems reasonable to me, your Option 4, with optional use of Option 3. The App has one large state tree with one reducer. You can break up responsibility into sub-reducers which are ultimately combined using combineReducers to create the overarching reducer. For local component state, Option 1&2 make sense to me. I tend to use Option 1. I agree with everything you've said. I haven't seen any new, innovative ways to use useReducer.Zanezaneski
It' a relief reading that there's not a magical way to do thing I was missing. Thanks for your comment!Pegues
I just stumbled across this useMethods reducer and thought of you. It's an interesting way to scaffold things.Zanezaneski
Cool! Thank you so much!Pegues
I
1

We recently dealt with similar situation using custom hook because reducer became too unpredictable. Idea was to create our state in custom hook, then we created typesafe helpers operating on state, and then we exposed state and helpers.

interface State{
  count: number;
}

interface ExportType{
  state: State;
  add: (arg: number)=>void;
  subtract: (arg: number)=>void;
}

export default function useAddRemove(): ExportType {

    const [state, setState] = useState<State>({
        count: 0
    })
    
    function add(arg:number){
      setState(state=>({...state, count: state.count+arg}))
    }
    
    function subtract(arg:number){
      setState(state=>({...state, count: state.count-arg}))
    }


    return {
        state,
        add,
        subtract,
    }
}

Please let me know if you have any suggestions.

Incomparable answered 30/4, 2020 at 19:46 Comment(3)
It's a cool approach! It's the option 1 encapsulated in a Custom Hook! It'll be more complex that a counter, with a lot of methods to update the complex state, but there's more mantainable that writting it inside the component. I'll take it in consideration! Thank you so much!Pegues
Welcome @Genarito. Happy to help.Incomparable
You probably want to wrap add and subtract into useCallback to avoid changing their identity on every useAddRemove, right?Gymnosperm
E
2

I think your observations are spot on.

I think you should use Option 1 for simple state (e.g. you have only a few items to keep track of), and Option 2 for complex state (lots of items, or nested items).

Options 1 and 2 are also the most readable and declarative.

Option #2 is talked about here: https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables

useReducer is more useful when you have multiple types of actions. If you're just updating state (one type of action), then actions are overkill. Also, useReducer is useful if you're performing calculations or transformations based on previous state (not just replacing part of state). If you're familiar with Redux, useReducer is a simplified version of Redux principles.

Eliseoelish answered 30/4, 2020 at 19:34 Comment(1)
Thanks for the answer and the link! It's helpfullPegues
P
2

Little late to answer, but want to help anyone else trying to figure out how to do this. This is basically a reducer to combine all of your state variables so you can update them with a single dispatch call. You can choose to update a a single attribute, or all of them at the same time.

type State = {
  lastAction: string
  count: number
}

const reducer = (
  state: State,
  action: Partial<State>,
) => ({
  ...state,
  ...action,
})

const INITIAL_STATE: State = {
  lastAction: ''
  count: 0
}

const ComponentWithState = () => {
  const [state, dispatchState] = useReducer(reducer, INITIAL_STATE)

  // ... other logic

  const increment = () => {
    dispatchState({lastAction: 'increment', count: state.count + 1})
  }

  const decrement = () => {
    dispatchState({lastAction: 'decrement', count: state.count - 1})
  }

  return (
    // UI
  )
}
Paralyze answered 26/2 at 14:55 Comment(0)
R
1

I would typically go for either option 2 or option 4 for a lot of state. Option 2 is fine if your data is easily updated, isn't nested, and doesn't have interdependence between the fields.

Option 4 is great because you can get a lot of more complicated behavior easily. I.e. updating fetching and error when you set the data for an asynchronous fetch operation. It is also great because you can pass the dispatch function down to child components for them to use to update the state.

Here's an example I put together using redux toolkit to strongly type a reducer that uses combineReducers for use in useReducer.

https://codesandbox.io/s/redux-toolkit-with-react-usereducer-2pk6g?file=/src/App.tsx

  const [state, dispatch] = useReducer<Reducer<ReducerType>>(reducer, {
    slice1: initialState1,
    slice2: initialState2
  });


const initialState1: { a: number; b: string } = { a: 0, b: "" };
const slice1 = createSlice({
  name: "slice1",
  initialState: initialState1,
  reducers: {
    updateA(state, action: PayloadAction<number>) {
      state.a += action.payload;
    },
    updateB(state, action: PayloadAction<string>) {
      state.b = action.payload;
    }
  }
});

const initialState2: { c: number; d: number } = { c: 0, d: 0 };
const slice2 = createSlice({
  name: "slice2",
  initialState: initialState2,
  reducers: {
    updateC(state, action: PayloadAction<number>) {
      state.c += action.payload;
    },
    updateD(state, action: PayloadAction<number>) {
      state.d += action.payload;
    },
    updateCDD(state, action: PayloadAction<number>) {
      state.c += action.payload;
      state.d += action.payload * 2;
    }
  }
});

const reducer = combineReducers({
  slice1: slice1.reducer,
  slice2: slice2.reducer
});
type ReducerType = ReturnType<typeof reducer>;
Rectory answered 30/4, 2020 at 19:33 Comment(1)
Thanks for the answer! Unfortunatelly I can't/don't want to use Redux, thats why I would prefer a pure React + Typescript solution. But it made your point clearer. Thank you againPegues
I
1

We recently dealt with similar situation using custom hook because reducer became too unpredictable. Idea was to create our state in custom hook, then we created typesafe helpers operating on state, and then we exposed state and helpers.

interface State{
  count: number;
}

interface ExportType{
  state: State;
  add: (arg: number)=>void;
  subtract: (arg: number)=>void;
}

export default function useAddRemove(): ExportType {

    const [state, setState] = useState<State>({
        count: 0
    })
    
    function add(arg:number){
      setState(state=>({...state, count: state.count+arg}))
    }
    
    function subtract(arg:number){
      setState(state=>({...state, count: state.count-arg}))
    }


    return {
        state,
        add,
        subtract,
    }
}

Please let me know if you have any suggestions.

Incomparable answered 30/4, 2020 at 19:46 Comment(3)
It's a cool approach! It's the option 1 encapsulated in a Custom Hook! It'll be more complex that a counter, with a lot of methods to update the complex state, but there's more mantainable that writting it inside the component. I'll take it in consideration! Thank you so much!Pegues
Welcome @Genarito. Happy to help.Incomparable
You probably want to wrap add and subtract into useCallback to avoid changing their identity on every useAddRemove, right?Gymnosperm

© 2022 - 2024 — McMap. All rights reserved.