4

I'm trying to use toString to temporarily output a class to the DOM. I'm getting some behaviour I don't understand where the overriden toString() will always output the initial state. However, if an external function is used (i.e. stateToString) or even JSON.stringify, the updated state is outputted as I would expect.

Below is my attempt to minimally reproduce this behaviour. To reiterate, my expected behaviour is for all of them to initially output: ["initial"], which they do. However, the toString() output does not update when the button is clicked but the other two do.

This seems particularly strange as stateToString and State.toString seem to be essentially identical functions, except one takes state as a receiver and one takes state as a parameter.

I'd appreciate if someone could explain why this behaviour occurs.

import React, { useReducer } from 'react';

class State { 
  constructor(xs) { this.xs = xs } 
  toString = () => `[${this.xs}]`
}

const stateToString = state => `[${state.xs}]`;

const reducer = (state, action) => ({
  ...state,
  xs: [...state.xs, action.x]
});

const App = () => {
  const [state, dispatch] = useReducer(reducer, new State(["initial"]));
  return (
    <div>
      <button onClick={() => dispatch({ x: Math.random() })}>click</button><br />
      toString:  {state.toString()}<br />
      print:     {stateToString(state)}<br />
      stringify: {JSON.stringify(state)}
    </div>
  );
};

export default App;
CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
bobluza
  • 131
  • 1
  • 3

1 Answers1

4

The toString method you put on the State is bound to the original instance of the state:

class State { 
  constructor(xs) { this.xs = xs } 
  toString = () => `[${this.xs}]` // Class field arrow function
}

The class field there means that no matter what calling context the toString is called with, it will return the this.xs of the initial State. Even if the reducer updates the state, the state's constructor does not run again.

On later calls of App, the initial state is created, and then goes through some actions to update it, resulting in the state variable being an updated object, but it still has a toString method bound to the initial state.

Here's an example of the behavior in vanilla JS:

const obj = {
  val: 'val',
  toString: () => obj.val
};

const copiedObj = { ...obj, val: 'newVal' };
console.log(copiedObj.toString());

If you assigned a function rather than an arrow function, then the toString would be called with a calling context of the updated state, because it's not bound to the initial state, so it'll be called with a calling context of the updated state and properly retrieve the xs:

toString = function () {
    return `[${this.xs}]`;
}

As a side note, you can't use an ordinary method like

toString() {
    return `[${this.xs}]`;
}

because in your reducer:

const reducer = (state, action) => ({
    ...state,
    xs: [...state.xs, action.x]
});

spread syntax only takes enumerable own properties. With method syntax (like toString() {), the property is put on the State prototype, not the actual instance, so it won't exist in the final state, and the built-in Object.prototype.toString will be invoked instead.

CertainPerformance
  • 356,069
  • 52
  • 309
  • 320