Home > Net >  Error on using spread operator with nested object and useReducer hook
Error on using spread operator with nested object and useReducer hook

Time:11-06

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

CodePudding user response:

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. I think a good 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

CodePudding user response:

Very odd error message indeed. But if you add a return type to your reducer function reducer(state: PersonType, action: ActionType): PersonType { you get another error message that is more sensible: action.value might be a number and that is not assignable to firstName. So you should probably create more specified ActionTypes.

interface numberAction {
  type: string;
  fieldName: "firstName" /* | possibly other */;
  value: string;
}
interface stringAction {
  type: string;
  fieldName: "age" /* | possibly other */;
  value: number;
}
type ActionType = numberAction | stringAction
  • Related