Zeno Dalla Valle
Zeno Dalla Valle

Reputation: 919

react useCallback not updating function

Isn't the hook useCallback supposed to return an updated function every time a dependency change?

I wrote this code sandbox trying to reduce the problem I'm facing in my real app to the minimum reproducible example.

import { useCallback, useState } from "react";

const fields = [
  {
    name: "first_name",
    onSubmitTransformer: (x) => "",
    defaultValue: ""
  },
  {
    name: "last_name",
    onSubmitTransformer: (x) => x.replace("0", ""),
    defaultValue: ""
  }
];

export default function App() {
  const [instance, setInstance] = useState(
    fields.reduce(
      (acc, { name, defaultValue }) => ({ ...acc, [name]: defaultValue }),
      {}
    )
  );

  const onChange = (name, e) =>
    setInstance((instance) => ({ ...instance, [name]: e.target.value }));

  const validate = useCallback(() => {
    Object.entries(instance).forEach(([k, v]) => {
      if (v === "") {
        console.log("error while validating", k, "value cannot be empty");
      }
    });
  }, [instance]);

  const onSubmit = useCallback(
    (e) => {
      e.preventDefault();
      e.stopPropagation();
      setInstance((instance) =>
        fields.reduce(
          (acc, { name, onSubmitTransformer }) => ({
            ...acc,
            [name]: onSubmitTransformer(acc[name])
          }),
          instance
        )
      );
      validate();
    },
    [validate]
  );

  return (
    <div className="App">
      <form onSubmit={onSubmit}>
        {fields.map(({ name }) => (
          <input
            key={`field_${name}`}
            placeholder={name}
            value={instance[name]}
            onChange={(e) => onChange(name, e)}
          />
        ))}

        <button type="submit">Create object</button>
      </form>
    </div>
  );
}

This is my code. Basically it renders a form based on fields. Fields is a list of objects containing characteristics of the field. Among characteristic there one called onSubmitTransformer that is applied when user submit the form. When user submit the form after tranforming values, a validation is performed. I wrapped validate inside a useCallback hook because it uses instance value that is changed right before by transform function.

To test the code sandbox example please type something is first_name input field and submit.

Expected behaviour would be to see in the console the error log statement for first_name as transformer is going to change it to ''.

Problem is validate seems to not update properly.

Upvotes: 2

Views: 5789

Answers (1)

Henry Woody
Henry Woody

Reputation: 15662

This seems like an issue with understanding how React lifecycle works. Calling setInstance will not update instance immediately, instead instance will be updated on the next render. Similarly, validate will not update until the next render. So within your onSubmit function, you trigger a rerender by calling setInstance, but then run validate using the value of instance at the beginning of this render (before the onSubmitTransformer functions have run).

A simple way to fix this is to refactor validate so that it accepts a value for instance instead of using the one from state directly. Then transform the values on instance outside of setInstance.

Here's an example:

function App() {
    // setup

    const validate = useCallback((instance) => {
        // validate as usual
    }, []);

    const onSubmit = useCallback((e) => {
        e.preventDefault();
        e.stopPropagation();
        const transformedInstance = fields.reduce((acc, {name, onSubmitTransformer}) => ({
            ...acc,
            [name]: onSubmitTransformer(acc[name]),
        }), instance);
        setInstance(transformedInstance);
        validate(transformedInstance);
    }, [instance, validate]);

    // rest of component
}

Now the only worry might be using a stale version of instance (which could happen if instance is updated and onSubmit is called in the same render). If you're concerned about this, you could add a ref value for instance and use that for submission and validation. This way would be a bit closer to your current code.

Here's an alternate example using that approach:

function App() {
    const [instance, setInstance] = useState(/* ... */);

    const instanceRef = useRef(instance);
    useEffect(() => {
        instanceRef.current = instance;
    }, [instance]);

    const validate = useCallback(() => {
        Object.entries(instanceRef.current).forEach(([k, v]) => {
            if (v === "") {
              console.log("error while validating", k, "value cannot be empty");
            }
          });
    }, []);

    const onSubmit = useCallback((e) => {
        e.preventDefault();
        e.stopPropagation();
        const transformedInstance = fields.reduce((acc, {name, onSubmitTransformer}) => ({
            ...acc,
            [name]: onSubmitTransformer(acc[name]),
        }), instanceRef.current);
        setInstance(transformedInstance);
        validate(transformedInstance);
    }, [validate]);

    // rest of component
}

Upvotes: 5

Related Questions