reactjstypescriptreact-hooksreact-typescriptuse-reducer

Error on using spread operator with nested object and useReducer hook


I have following nested object as state.

interface name {
  firstName: string;
  lastName: string;
}
type NameType = name;

interface employer {
  name: string;
  state: string;
}
type EmployerType = employer;

interface person {
  name: NameType;
  age: number;
  employer: EmployerType;
}
type PersonType = person;

const defaultPerson: PersonType = {
  name: {
    firstName: "The",
    lastName: "Rock"
  },
  age: 25,
  employer: {
    name: "Noone",
    state: "Nowhere"
  }
};

To update the nested object in state when I use [...] spread operator at second level in case of useState hook, it works just as expected See this working code.

export default function App() {
  const [person, setPerson] = useState<PersonType>(defaultPerson);
  function handleInputChange(input: string) {
    setPerson({
      ...person,
      name: {
        ...person.name,
        firstName: input
      }
    });
  }

  return (
    <div className="App">
      <input onChange={(e) => handleInputChange(e.target.value)} />
      <h2>{JSON.stringify(person, null, 4)}</h2>
    </div>
  );
}

But if I do same thing with a reducer and useReducer hook, Typescript is not liking it and gives error that I am not able to understand. The type error can be seen in codesandbox. See this broken code.

interface action {
  type: string;
  fieldName: string;
  value: string | number;
}
type ActionType = action;

function reducer(state: PersonType, action: ActionType) {
  switch (action.type) {
    case "firstName": {
      return {
        ...state,
        name: {
          ...state.name,
          firstName: action.value
        }
      };
    }
  }
  return state;
}

export default function App() {
  const [person, dispatch] = useReducer(reducer, defaultPerson);

  return (
    <div className="App">
      <input
        onChange={(e) =>
          dispatch({
            type: "test",
            fieldName: "firstName",
            value: e.target.value
          })
        }
      />
      <h2>{JSON.stringify(person, null, 4)}</h2>
    </div>
  );
}

Though I am able to cope the state to an entirely new object and update that new object instead, but that feels like a hack and not the right way.

const newState = {...state}
const newName = {...newState.name}
newName.firstName = action.value
newState.name = newName
return newState

Solution

  • interface anti-pattern

    You are using interface and type unconventionally. Don't create an interface for every type -

    type Name = {
      firstName: string;
      lastName: string;
    };
    
    type Employer = {
      name: string;
      state: string;
    };
    
    type Person = {
      name: Name;
      age: number;
      employer: Employer;
    };
    

    We should specify the Person return type for reducer even though TS can figure it out. However we have another problem, Action type doesn't match -

    function reducer(state: Person, action: Action) {
      switch (action.type) {
        case "firstName":
          return {
            ...state,
            name: {
              ...state.name,
              firstName: action.value // <- string | number
            }
          };
        default:
          return state;
      }
    }
    

    We could just make action.value a string, but that means our reducer will only be able to set string values ...

    type Action = {
      type: string;
      fieldName: string;
      value: string;  // ?
    };
    

    kinds of actions

    Having an Action that can only set strings would not be very useful. A good solution for supporting a wide variety of well-type actions would be a tagged union -

    enum ActionKind {
      SetName,
      SetAge,
      SetEmployer
    }
    
    type Action =
      | { kind: ActionKind.SetName; name: Name }
      | { kind: ActionKind.SetAge; age: number }
      | { kind: ActionKind.SetEmployer; employer: Employer };
      | ...
      | ...
      | ...
    
    function reducer(state: Person, action: Action): Person {
      switch (action.kind) {
        case ActionKind.SetName:
          return { ...state, name: action.name };
        case ActionKind.SetAge:
          return { ...state, age: action.age };
        case ActionKind.SetEmployer:
          return { ...state, employer: action.employer };
        default:
          return state;
      }
    }
    

    Don't forget to fix dispatch with our new Action type -

    <input
      onChange={(e) =>
        // update user's first name
        dispatch({
          kind: ActionKind.SetName,
          name: { firstName: e.target.value, lastName: person.name.lastName }
        })
      }
    />
    

    Run demo on codesandbox.com