javascriptreactjstypescript

React Typescript toggle not working as expected


Creating a mini app to demonstrate the problem I'm having with React & Typescript

Here is a simple toggle component

import { MouseEvent, useState } from "react";

interface ToggleParams {
  label?: string;
  value?: boolean;
  disabled?: boolean;
  onToggleClick?: (
    event: MouseEvent<HTMLButtonElement>,
    value: boolean
  ) => void;
}

const Toggle = ({
  label = "",
  value = false,
  disabled = false,
  onToggleClick = () => {},
}: ToggleParams) => {
  const [enabled, setEnabled] = useState(value);

  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    if (disabled) return;
    console.log("handleClick > enabled", enabled);
    const newValue = !enabled;
    console.log("handleClick > newValue", newValue);
    setEnabled(value);
    onToggleClick(event, newValue);
  };

  return (
    <button
      onClick={handleClick}
      disabled={disabled}
      className="focus:outline-none"
    >
      {`${enabled ? "true" : "false"} ${label}`}
    </button>
  );
};

export default Toggle;

here is the code that tests this component

import { useState } from "react";
import Toggle from "./Toggle";

export default function App() {
  const [toggleAll, setToggleAll] = useState(false);
  const [toggleItem, setToggleItem] = useState(false);
  const [toggleDisabled, setToggleDisabled] = useState(false);

  console.log("Toggle state", toggleAll, toggleItem, toggleDisabled);

  const onToggleAllClick = (event: any, value: boolean) => {
    setToggleAll(value);
    setToggleItem(value);
    setToggleDisabled(value);
  };

  const onToggleItemClick = (event: any, value: boolean) => {
    setToggleItem(value);
    if (!value) {
      setToggleAll(value);
      setToggleDisabled(value);
    }
  };

  return (
    <div className="App">
      <div>
        <Toggle
          value={toggleAll}
          label={"Toggle All"}
          onToggleClick={onToggleAllClick}
        />
      </div>
      <div>
        <Toggle
          value={toggleItem}
          label={"Toggle Item"}
          onToggleClick={onToggleItemClick}
        />
      </div>
      <div>
        <Toggle
          value={toggleDisabled}
          label={"Toggle Disabled"}
          disabled={true}
        />
      </div>
    </div>
  );
}

The toggle should change state when I click them, however they don't on the 1st click

When I click the Item toggle, the console correctly shows

handleClick > enabled false
handleClick > newValue true
Toggle state false true false

indicating that the code works, however the display shows

false Toggle All
false Toggle Item
false Toggle Disabled

which means the toggle component itself didn't change

clicking it again, then changes the display correctly to this

false Toggle All
true Toggle Item
false Toggle Disabled

the console output doesn't change

I refresh the page to start again

When I click the All toggle, all the items should be true, and the console correctly shows

handleClick > enabled false
handleClick > newValue true
Toggle state true true true

however the display incorrectly shows

false Toggle All
false Toggle Item
false Toggle Disabled

all these should have changed from false to true

when I click All toggle again, the console is the same, but the display changes to

true Toggle All
false Toggle Item
false Toggle Disabled

toggle All display changed correctly this time, but the others should have changed as well

Bug: toggles need to be clicked twice before they change their own value on the display

now click item toggle (once), the item toggle now displays true and only needed 1 click

now click item toggle again (twice) so that it shows false

the display changes to

handleClick > enabled true
handleClick > newValue false
Toggle state false false false

the code correctly changed all the values to false here in the console according to onToggleItemClick, however the display still incorrectly shows

true Toggle All
false Toggle Item
false Toggle Disabled

Bug: toggles need to be change their value when the underlying value from the code changes

Bug: the disabled toggle should also change its value when the underlying value from the code changes

what do I need to do to correctly show get the toggle to display the correct value?

Here is a sandbox https://codesandbox.io/p/sandbox/vibrant-jackson-44g6xt


Solution

  • You're unnecessarily complicating it by giving the Toggle component it's own state. So there's actually two problems in the code:

    The first is that in the handleClick function, you are setting enabled to the current value, not the new value, this is what's creating the "first-click" delay:

    // ...
    const newValue = !enabled;
    setEnabled(value); // instead you should use `newValue`
    onToggleClick(event, newValue);
    // ...
    

    So here, enabled will always be one change behind the actual state from the parent. value is not yet updated to the new value until the next render and that's what you are setting for enabled. The next render comes along and value is now updated but enabled is not.

    The second problem is your use of state in the Toggle component in the first place, this creates a separate state for each Toggle instance that is not connected to the parent state apart from providing an initial value (value):

    // ...
    const [enabled, setEnabled] = useState(value);
    // ...
    {`${enabled ? "true" : "false"} ${label}`}
    // any changes to `value` won't have any effect on `enabled`
    

    I think what you mainly want though is for the parent state to be the single source of truth, to do this you should remove the separate state inside the Toggle component and just use value.