Genarito
Genarito

Reputation: 3433

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

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

Upvotes: 6

Views: 5300

Answers (4)

Brandon Minner
Brandon Minner

Reputation: 49

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

Upvotes: 2

gautamits
gautamits

Reputation: 1292

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.

Upvotes: 1

stevenkkim
stevenkkim

Reputation: 428

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.

Upvotes: 2

Zachary Haber
Zachary Haber

Reputation: 11027

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

Upvotes: 1

Related Questions