mardi 14 avril 2020

Patterns: Correct way to implement a configuration menu in React

There is one thing that I don't understand how to do correctly in pure React (without Redux and Co for a lot of reasons that I don't want to explain here), and we can simplify the problem by studying a dummy checkbox that we scale up into a whole configuration menu.

I see 3 ways of achieving this and all seems to have downs when you want to scale up. Let's see.

 Stateless checkbox

const Checkbox = ({
  onPress = () => {},
  checked = false
}) => {
  return <input type="checkbox" checked={checked} />
}

Now, if I have a full configuration menu with many checkbox, this means that a parent should have a state containing all the state of all checkboxes, and provide everything as props to the menu. See:

const Parent = () => {
  const [isBox1Checked, checkBox1] = useState(false)
  const [isBox2Checked, checkBox2] = useState(false)
  const [isBox3Checked, checkBox3] = useState(false)
  const [isBox4Checked, checkBox4] = useState(false)
  const [isBox5Checked, checkBox5] = useState(false)
  return <Menu isBox1Checked={isBox1Checked} checkBox1={checkBox1} ... />
}

I could use an array, but keep in mind that what is simplified here as checkboxes would be all kind of parameters (numbers, booleans, strings, etc)

The menu would be like:

const Menu = ({isBox1Checked, checkBox1, ... }) => {
  return <div>
    <Checkbox onPress={checkBox1} checked={isBox1Checked} />
    <Checkbox onPress={checkBox2} checked={isBox2Checked} />
    <Checkbox onPress={checkBox3} checked={isBox3Checked} />
    <Checkbox onPress={checkBox4} checked={isBox4Checked} />
    <Checkbox onPress={checkBox5} checked={isBox5Checked} />
  </div>
}

Pros:

  • Easy to read the current configuration where it is needed

Cons:

  • A lot of stupid code to write just to pass down all the props
  • Maintenance and refacto will probably be a nightmare...

Stateful checkbox

const Checkbox = ({
  onChange = () => {},
  initialValue = false
}) => {
  const [checked, setChecked] = useState(initialValue)
  const toggle = () => {
    setChecked(!checked)
    onChange(!checked)
  }
  return <input type="checkbox" checked={checked} onChange={toggle}/>
}

Here, we store the state of the checkbox within the checkbox, which becomes the owner of the truth. Relatives can learn about state changes with the onChange method.

Unfortunately, the Parent and the Menu component will look exactly like the previous ones...

Pros:

  • More Component oriented checkbox, which is better for separation of concerns. Cons:

  • A lot of stupid code to write just to pass down all the props

  • Maintenance and refacto will probably be a nightmare...
  • Duplication of the data as there is a state for the checkbox within the Checkbox, and a state for the functionality within the Parent...
  • It could get confusing to provide a initialValue as a props that would be ignored when updated, as the reality resides within the state

Reading the state with refs

const Checkbox = forwardRef(({
  onChange = () => {},
  initialValue = false
}, ref) => {
  const [checked, setChecked] = useState(initialValue)
  //We wait for the DOM to be rendered for ref.current to worth something.
  useLayoutEffect(() => ref.current.isChecked = () => checked, [ref.current])
  const toggle = () => {
    setChecked(!checked)
    onChange(!checked)
  }
  return <input ref={ref} type="checkbox" checked={checked} onChange={toggle}/>
})

This time, the checkbox becomes the real owner of the truth, and to read the data, we read it directly within the checkbox. The parent would become something like that:

const Parent = () => {
  const menu = createRef()
  const onConfigChanged = ({ name, value }) => //Do sth if sth changed
  const isBox1Checked = menu.current ? menu.current.isChecked('box1') : false
  //Do something with valueOfParam1
  return <Menu ref={menu} onChange={onConfigChanged} />
}

const Menu = forwardRef({ onConfigChanged }, ref) => {
  // We create the refs for all the configs that this menu will be in charge for
  const configs = useRef({
    box1: createRef(),
    box2: createRef(),
    box3: createRef(),
    box4: createRef(),
    box5: createRef(),
  })
  // We provide a method to read the state of each checkbox by accessing their ref.
  if(ref.current) ref.current.isChecked = name => configs[name].current.isChecked
  const onChanged = name => value => onConfigChanged({name, value})
  return <div ref={ref}>
    <Checkbox ref={configs.current.box1} onChange={onConfigChanged('box1')} />
    <Checkbox ref={configs.current.box2} onChange={onConfigChanged('box2')} />
    <Checkbox ref={configs.current.box3} onChange={onConfigChanged('box3')} />
    <Checkbox ref={configs.current.box4} onChange={onConfigChanged('box4')} />
    <Checkbox ref={configs.current.box5} onChange={onConfigChanged('box5')} />
  </div>
})

Pros:

  • 100% Component oriented, with separation of concerns
  • Parent code is simplified, that helps highlight their own responsibility instead of flooding it with state distribution

Cons:

  • Supposedly anti-pattern of using refs to access children methods
  • It could get confusing to provide a initialValue as a props that would be ignored when updated, as the reality resides within the state
  • The current Parent implementation has a flaw as it doesn't rerender when a config changed.

So what is the correct way to implement this?

Aucun commentaire:

Enregistrer un commentaire