Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

optional controlled / uncontrolled React component with conditional React hooks

IMO, React Hooks useState is a perfect fit for a pattern to optional using value from props or use own state, but the lint showed some error when I use hook conditionally.

Working Example

I tried to use hooks with condition as below but with eslint error React hook useState is called conditionally. According to this explanation from doc, React relies on the order in which Hooks are called.

const Counter = ({ value, onChange, defaultValue = 0 }) => {
  const [count, onCountChange] =
    typeof value === "undefined" ? useState(defaultValue) : [value, onChange];
  return (
    <div>
      {count.toString()}
      <button
        onClick={() => {
          onCountChange(count + 1);
        }}
      >
        +
      </button>
    </div>
  );
};
function App() {
  const [count, onCountChange] = useState(0);
  return (
    <div className="App">
      <div>
        Uncontrolled Counter
        <Counter />
      </div>
      <div>
        Controlled Counter
        <Counter value={count} onChange={onCountChange} />
      </div>
    </div>
  );
}

How can I use hooks to achieve same function as below class Component ?

class CounterClass extends React.Component {
  state = {
    value: this.props.defaultValue || 0
  };
  render() {
    const isControlled = typeof this.props.defaultValue === "undefined";
    const count = isControlled ? this.props.value : this.state.value;

    return (
      <div>
        {count.toString()}
        <button
          onClick={() => {
            isControlled &&
              this.props.onChange &&
              this.props.onChange(this.props.value + 1);
            !isControlled && this.setState({ value: this.state.value + 1 });
          }}
        >
          +
        </button>
      </div>
    );
  }
}

Or this kind props/state optional way in one component is wrong?

I learnt the "defaultValue", "value", "onChange" API naming and idea from React JSX <input> component.

like image 249
JasonHsieh Avatar asked Apr 17 '19 14:04

JasonHsieh


2 Answers

You could split your component into fully controlled and fully uncontrolled. Demo

const CounterRepresentation = ({ value, onChange }) => (
  <div>
    {value.toString()}
    <button
      onClick={() => {
        onChange(value + 1);
      }}
    >
      +
    </button>
  </div>
);

const Uncontrolled = ({ defaultValue = 0 }) => {
  const [value, onChange] = useState(defaultValue);
  return <CounterRepresentation value={value} onChange={onChange} />;
};

// Either use representation directly or uncontrolled
const Counter = ({ value, onChange, defaultValue = 0 }) => {
  return typeof value === "undefined" ? (
    <Uncontrolled defaultValue={defaultValue} />
  ) : (
    <CounterRepresentation value={value} onChange={onChange} />
  );
};
like image 109
Yury Tarabanko Avatar answered Oct 04 '22 11:10

Yury Tarabanko


Great question! I think this can be solved with hooks by making the useState call unconditional and only making the part conditional where you decide which state you render and what change handler you use.

I've just released a hook which solves this: use-optionally-controlled-state

Usage:

import useOptionallyControlledState from 'use-optionally-controlled-state';
 
function Expander({
  expanded: controlledExpanded,
  initialExpanded = false,
  onChange
}) {
  const [expanded, setExpanded] = useOptionallyControlledState({
    controlledValue: controlledExpanded,
    initialValue: initialExpanded,
    onChange
  });
 
  function onToggle() {
    setExpanded(!expanded);
  }
 
  return (
    <>
      <button onClick={onToggle} type="button">
        Toggle
      </button>
      {expanded && <div>{children}</div>}
    </>
  );
}

// Usage of the component:

// Controlled
<Expander expanded={expanded} onChange={onExpandedChange} />
 
// Uncontrolled using the default value for the `initialExpanded` prop
<Expander />
 
// Uncontrolled, but with a change handler if the owner wants to be notified
<Expander initialExpanded onChange={onExpandedChange} />

By using a hook to implement this, you don't have to wrap an additional component around and you can theoretically apply this to multiple props within the same component (e.g. a <Prompt isOpen={isOpen} inputValue={inputValue} /> component where both props are optionally controlled).

like image 30
amann Avatar answered Oct 04 '22 12:10

amann