Synchronous State With React Hooks

Adam Nathaniel Davis - Dec 22 '20 - - Dev Community

[NOTE: Since I wrote this article, I've turned this code into an NPM package that can be found here: https://www.npmjs.com/package/@toolz/use-synchronous-state]

Since I've converted my dev to React Hooks (rather than class-based components), I keep running head-first into the asynchronous nature of state updates. I don't honestly understand why this rarely seemed like a problem in my class-based components. But with functions/Hooks, I keep hitting this "roadblock". And unlike other articles I've written, this isn't my cocky declaration that I have solved ALL THE THINGS!!! Rather, I'd be thrilled if someone can show me an improvement on my proposed solution.


Alt Text

The Problem

We have a complex form. There are field-level validations. And form-level validations. And some of those validations vary based on the values entered into other fields.

Because the user's path through the form is not always linear, the logic in the component is broken up, as much as possible, into small manageable functions. So for example, when you update the Member ID field, it calls updateMemberId(). Then it calls validateMemberId(), to see if we should show any error messages to the user. Then it calls validateForm(), to see if we should be checking all of the other fields on the form.

So the code ends up looking something like this:

export default function App() {
  const [memberId, setMemberId] = useState('');
  const [validateEntireForm, setValidateEntireForm] = useState(false);

  const updateMemberId = userValue => {
    setMemberId(userValue);
    validateMemberId();
    if (validateEntireForm)
      validateForm();
  }

  const validateForm = () => {
    if (!validateEntireForm)
      setValidateEntireForm(true);
    validateMemberId();
    // validate the rest of the fields in the form  
  }

  const validateMemberId = () => {
    // validate based on the CURRENT value of 'memberId'
    return validOrNot;
  }

  return (<>UX Here...</>);
}
Enter fullscreen mode Exit fullscreen mode

I won't ask you to mentally "load" this pseudo-code. So I'll just tell you the problem that I run into: Tabbing out of the memberId field triggers updateMemberId(), which in turn updates the state value of memberId, which then leads to calling validateMemberId(). Inside validateMemberId(), we'll be referencing the state value for memberId - the value that was set microseconds previously inside updateMemberId().

Of course, even though the value of the memberId state variable was updated during updateMemberId(), what happens when validateMemberId() tries to reference that same variable? That's right, it doesn't see the latest value of memberId. In fact, it sees whatever was saved into memberId during the previous update. So validateMemberId() is always one update behind.

Of course, this problem is only exacerbated if we've flipped the validateEntireForm flag. Because once validateForm() gets called, it will also lead to referencing the value of memberId - which will still be stuck on the previous value.

The "problem" is pretty simple - and one that has been inherent in React since it was created. State updates are asynchronous. This was true in class-based components. It's true with functions/Hooks. But for whatever reason, I've only recently been running into ever-more headaches from this basic fact.

Since setMemberId() is asynchronous, subsequent references to memberId don't reflect the most up-to-date value that was just entered by the user. They reference the previous value. And that obviously throws off the validation.


Alt Text

Standard (Poor) Solutions

There are several "standard" ways to address this problem. In some situations, they might be appropriate. But in most scenarios, I really don't care for them at all. They include:

  1. Consolidate all these functions into one routine. If it's all one function, then we can set one temp variable for the new field value, then use that same temp variable to update the field's state variable, and to check for field-level validity, and to check for global form validity. But if the "solution" is to stop creating small, targeted, single-use functions, well then... I don't really want to pursue that "solution" at all.

  2. Explicitly pass the values into each function. For example, updateMemberId() could grab the newly-entered value and pass it into validateMemberId(). But I don't like that. Why??? Well, because in this example, the state variable is the system of record. In other words, I don't want validateMemberId() to only validate whatever value was blindly passed into it. I want that function to validate the current state value. And if that's to occur, the function should always be looking back into state to grab the latest value. I've also found that, when building complex user interactions, there can sometimes be many different scenarios where a validation needs to be checked. And during those scenarios, there's not always a convenient variable to pass into the validation function. During those scenarios, it makes far more sense for the validation function to just grab the state value on its own.

  3. Use reducers. I dunno. Maybe it's because I hate Redux, but I really dislike feeling compelled to convert most of my calls to useState() into useReducer(). Once you go down the useReducer() path, more and more and more of your logic ends up getting sucked out of your components and into all of these helper functions. And once it's sitting in all those helper functions, most devs feel compelled to start sorting them off into their own separate card catalog of directories. Before you know it, your previously-simple component has become an 8-file octopus of confusion.

  4. Use useRef()?? I've seen several references to this on the interwebs. Honestly, any time I start following this rabbit hole, I end up burning precious hours and getting no closer to a solution. If useRef() is the answer to this problem, I'd love to see it. But so far... it seems lacking.

  5. Use useEffect() Stop. No, seriously. Just... stahp. I've seen several threads on the interwebs suggesting that the "solution" to this quandary is to leverage useEffect(). The idea is that, for example, when we want to update memberId, we also create a call to useEffect() that handles all of the side effects that happen once we update memberId. But that often threatens to turn the logic of our components on its ear. It's not uncommon for me to have a component where changing one state value forces me to check on the values of several other state values. And once you start chunking all of that crap into the dependency array... well, you might as well just start building a whole new tree of Higher Order Components.

  6. Use the verbose version of the state variable's set function. This was the avenue I pursued for a while. But it can get, well... ugly. Consider this:

  const updateMemberId = async userValue => {
    let latestMemberId;
    await setMemberId(userValue => {
      latestMemberId = userValue;
      return userValue;
    });
    validateMemberId();
    if (validateEntireForm)
      validateForm();
  }
Enter fullscreen mode Exit fullscreen mode

This... doesn't really solve much. On one hand, once we're past the setMemberId() logic, we have the latest-greatest value saved in latestMemberId. But we already had that value saved in userValue and we'll still need to pass it into all of the downstream functions. Furthermore, we've started to litter up our logic with async/await - which is a problem when we have logic that shouldn't really be asynchronous.


Alt Text

The Problem - Simplified

The "problem" I'm trying to highlight can be distilled down to this basic issue:

const someFunction = someValue => {
  setSomeStateVariable(someValue);
  if (someConditionBasedOnSomeStateVariable) {
    //...won't trigger based on the new value of 'someStateVariable'
  }
  callAFollowOnMethod();
}

const callAFollowOnMethod = () => {
  if (someStateVariable)
    //...won't recognize the latest value of 'someStateVariable'
}
Enter fullscreen mode Exit fullscreen mode

If we want to distill this into an even simpler example, there are just some times when we really want to do something like this:

console.log(stateVariable); // 1
setStateVariable(2);
console.log(stateVariable); // 2
setStateVariable(3);
console.log(stateVariable); // 3
Enter fullscreen mode Exit fullscreen mode

In other words, sometimes, you really need to update a state variable and know that, very soon thereafter, you can retrieve the latest, most up-to-date value, without worrying about asynchronous effects.

To be absolutely clear, I fully understand that some things will always be, and should always be, asynchronous. For example, if you have three state variables that hold the responses that come back from three consecutive API calls, then of course those values will be set asynchronously.

But when you have three state variables that are consecutively set with three simple scalar values - well... it can be kinda frustrating when those values aren't available to be read immediately. In other words, if you can do this:

let foo = 1;
console.log(foo); // 1
foo = 2; 
console.log(foo); // 2
Enter fullscreen mode Exit fullscreen mode

Then it can be somewhat frustrating when you realize that you can't do this:

const [foo, setFoo] = useState(1);
console.log(foo); // 1
setFoo(2);
console.log(foo); // 1
Enter fullscreen mode Exit fullscreen mode

So... how do we address this???


Alt Text

Eureka(?)

Here's what I've been working with lately. It's dead-simple. No clever solution here. But it satisfies two of my main concerns:

  1. I want to always have a way to retrieve the absolute latest state value.

  2. I'd really like to have the new state value returned to me after state updates. This may not seem like that big-of-a-deal - but sometimes, I really wish that the built-in set() functions would simply return the new value to me. (Of course, they can't simply return the new value, because they're asynchronous. So all they could return would be a promise.)

To address these two issues, I created this (super crazy simple) Hook:

import { useState } from 'react';

export default function useTrait(initialValue) {
   const [trait, updateTrait] = useState(initialValue);

   let current = trait;

   const get = () => current;

   const set = newValue => {
      current = newValue;
      updateTrait(newValue);
      return current;
   }

   return {
      get,
      set,
   }
}
Enter fullscreen mode Exit fullscreen mode

[NOTE: I'm not really sold on the name "trait". I only used it because I felt it was too confusing to call it some version of "state". And I didn't want to call the Hook useSynchronousState because this isn't really synchronous. It just gives the illusion of synchronicity by employing a second tracking variable.]

This would get used like this:

const SomeComponent = () => {
  const counter = useTrait(0);

  const increment = () => {
    console.log('counter =', counter.get()); // 0
    const newValue = counter.set(counter.get() + 1);
    console.log('newValue =', newValue); // 1
    console.log('counter =', counter.get()); // 1
  }

  return (
    <>
      Counter: {counter.get()}
      <br/>
      <button onClick={increment}>Increment</button>
    </>
  );

  return (<>UX Here...</>);
}
Enter fullscreen mode Exit fullscreen mode

This is a reasonable impersonation of synchronicity. By using two variables to track a single state value, we can reflect the change immediately by returning the value of current. And we retain the ability to trigger re-renders because we're still using a traditional state variable inside the Hook.


Alt Text

Downsides

I don't pretend that this little custom Hook addresses all of the issues inherent in setting a state variable - and then immediately trying to retrieve the latest value of that state variable. Here are a few of the objections I anticipate:

  1. useTrait() doesn't work if the value being saved is returned in a truly asynchronous manner. For example, if the variable is supposed to hold something that is returned from an API, then you won't be able to simply set() the value and then, on the very next line, get() the proper value. This is only meant for variables that you wouldn't normally think of as being "asynchronous" - like when you're doing something dead-simple, such as saving a number or a string.

  2. It will always be at least somewhat inefficient. For every "trait" that's saved, there are essentially two values being tracked. In the vast majority of code, trying to fix this "issue" would be a micro-optimization. But there are certainly some bulky values that should not be chunked into memory twice, merely for the convenience of being able to immediately retrieve the result of set() operations.

  3. It's potentially non-idiomatic. As mentioned above, I'm fully aware that the Children of Redux would almost certainly address this issue with useReducer(). I'm not going to try to argue them off that cliff. Similarly, the Children of Hooks would probably try to address this with useEffect(). Personally, I hate that approach, but I'm not trying to fight that Holy War here.

  4. I feel like I'm overlooking some simpler solution. I've done the requisite googling on this. I've read through a pile of StackOverflow threads. I haven't grokked any better approach yet. But this is one of those kinda problems where you just keep thinking that, "I gotta be overlooking some easier way..."

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player