Rendering a functional component as JSX breaks React render reconciliation

Viewed 692

I want the users of my component to be able to pass in an object of components, and I want to allow them to use functional or class based components.

type InputComponents = Record<string,  React.ComponentType>

This allows either FC or class components but I didn't find definitive guidelines on how to render them.

const inputComponents = {
  a: ({ placeholder }) => ( // Functional component
    <input
      type="text"
      value={state.a}
      placeholder={placeholder}
      onChange={(e) => {
        const value = e.currentTarget.value;
        setState((s) => ({ ...s, a: value }));
      }}
    />
  ),
  b: InputClass // Class component
};

Both of the components render fine without Typescript complaining like this:

const A = inputComponents.a;
const B = inputComponents.b;

<A placeholder="Type something" />
<B placeholder="Type something" />

But this is actually misleading - The functional one (a) will loose focus every key press.

The only way to make the functional component not loose focus every key press is like this:

inputComponents.a({ placeholder: 'Type something' })

Typescript doesn't even consider that a valid way to render a component and yet it's the only fully working way..... it errors with "This expression is not callable." and also fails for the Class component so I have to do this:

// Render as JSX for class components and call as a function for FC ones...
Component.prototype.isReactComponent ? <Component placeholder={x} /> : Component({ placeholder: x })

You can see the problem in action here:

function SomeComponent({ inputComponents }) {
  const B = inputComponents.b;
  const C = inputComponents.c;

  return (
    <div className="SomeComponent">
      <p>This FC component doesn't loose focus:</p>
      {inputComponents.a({ placeholder: 'Type something' })}
      <p>This one does:</p>
      <B placeholder="Type something" />
      <p>Rendering a class component as JSX works though:</p>
      <C placeholder="Type something" />
    </div>
  );
}

class InputClass extends React.Component {
  state = {
    value: ''
  };
  render() {
    return (
      <input
        type="text"
        value={this.state.value}
        placeholder={this.props.placeholder}
        onChange={(e) => {
          this.setState({ value: e.currentTarget.value });
        }}
      />
    );
  }
}

function App() {
  const [state, setState] = React.useState({
    a: '',
    b: ''
  });

  const inputComponents = {
    a: ({ placeholder }) => (
      <input
        type="text"
        value={state.a}
        placeholder={placeholder}
        onChange={(e) => {
          const value = e.currentTarget.value;
          setState((s) => ({ ...s, a: value }));
        }}
      />
    ),
    b: ({ placeholder }) => (
      <input
        type="text"
        value={state.b}
        placeholder={placeholder}
        onChange={(e) => {
          const value = e.currentTarget.value;
          setState((s) => ({ ...s, b: value }));
        }}
      />
    ),
    c: InputClass
  };

  return (
    <div className="App">
      <SomeComponent inputComponents={inputComponents} />
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('app'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>

<div id="app"></div>

Is React broken? Or what is the correct way to handle this without relying on hacking TS errors and using internal stuff like isReactComponent? Thanks

1 Answers

The problem is that you are creating a new component (the function itself) on every rerender because you have inputComponents within the App. The same behaviour would happen to class component if you move class declaration to the inner scope

inputComponent = {
  c: class InputClass extends Component {}
}

To solve the problem you can move components map to the outer scope and pass state and setState as props. Or provide via context.

function SomeComponent({ inputComponents, args }) {
  const B = inputComponents.b;
  const C = inputComponents.c;

  return (
    <div className="SomeComponent">
      <p>This one does:</p>
      <B placeholder="Type something" {...args} />
      <p>Rendering a class component as JSX works though:</p>
      <C placeholder="Type something" {...args} />
    </div>
  );
}


class InputClass extends React.Component {
  state = {
    value: ''
  };
  render() {
    return (
      <input
        type="text"
        value={this.state.value}
        placeholder={this.props.placeholder}
        onChange={(e) => {
          this.setState({ value: e.currentTarget.value });
        }}
      />
    );
  }
}

  const inputComponents = {
    b: ({ placeholder, state, setState }) => (
      <input
        type="text"
        value={state.b}
        placeholder={placeholder}
        onChange={(e) => {
          const value = e.currentTarget.value;
          setState((s) => ({ ...s, b: value }));
        }}
      />
    ),
    c: InputClass
  };


function App() {
  const [state, setState] = React.useState({
    a: '',
    b: ''
  });

  return (
    <div className="App">
      <SomeComponent inputComponents={inputComponents} args={{state, setState}} />
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('app'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.development.min.js"></script>

<div id="app"></div>

Related