DEV Community

loading...
Cover image for Synchronous State With React Hooks

Synchronous State With React Hooks

bytebodger profile image Adam Nathaniel Davis Updated on ・8 min read

[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..."

Discussion (10)

pic
Editor guide
Collapse
havespacesuit profile image
Eric Sundquist

I think you missed Standard (Poor) Solution #7: useCallback

const validateMemberId = useCallback(() => {
    // validate based on the CURRENT value of 'memberId'
    // this function gets updated whenever memberId is updated,
    // so we know it will be the most recent id you just set
    return validOrNot;
}, [memberId]);
Enter fullscreen mode Exit fullscreen mode

It has downsides because now you need to wrap validateForm, updateMemberId and on up through the call chain with useCallback as well. If you have the react-hooks lint plugin installed, it will warn you to do this; otherwise these functions can be re-created with each render.

I've been looking into Recoil lately for situations like this, but I haven't started testing it out yet so I don't have any good thoughts on if it is applicable. Seems a lot simpler than Redux, though!

Collapse
bytebodger profile image
Adam Nathaniel Davis Author • Edited

Great feedback! I've only read about useCallback(). Haven't yet found a great time/place to use it. But you're right, this is definitely another one of the valid options.

As you've pointed out, it also has some drawbacks. And I don't personally know if I'd use it in this scenario (in place of my custom Hook). But I definitely think that useCallback() is a better alternative than useEffect(). And I'm thinking that, in some other scenarios where useEffect() drives me nuts, it might be because I should really be using useCallback()...

Collapse
tbroyer profile image
Thomas Broyer

Isn't one of your problems that you're not actually embracing the state concept? I mean, you have your form values in state, the validation result is directly derived from it, and rendering directly derived from both (you'll set your state into your form components' value, and conditionally display validation errors).

So, run validation each time you render, or if it's heavyweight, then use useMemo. If you can break down validation into smaller parts, each one in it's useMemo, then some can be skipped if their input (field value, or the result of another validation step) hasn't changed.

React is all about having a state and deriving rendering from it; and updating it when there's an event. So you can either run validation at the same time you update the state, to store validation result into the state as well; or run it at render time, memoizing its result.

Collapse
bytebodger profile image
Adam Nathaniel Davis Author

You're not wrong. But the reply is also a bit narrow in scope (probably because the examples I gave were purposely over-simplified, to avoid making it a code-specific case study).

On one level, sure, we should always strive to utilize state in a manner consistent with your description. On another level, I think it's a bit naïve to imagine that we can always do that. For example, there are times when the validation logic is sufficiently complex that I absolutely don't want for it to run at render time. Because if you run it at render time, that means it's going to run on every re-render.

Of course, you make a good reference to useMemo(). A tool like that can take the sting out of running something repeatedly on render. Admittedly, I need to get more comfortable with that valuable tool.

But I guess the deeper, simplified issue is this:

Setting a state variable feels very similar to setting any other type of variable. Yeah, we know that it triggers some very different stuff in the background. But it's extremely common, in any language or style of coding, to set a variable on one line, and then, a handful of microseconds later, in some other block of code, check on the value in that same variable.

So the question becomes, if you have a scenario in React where you've set a state variable on one line, and then, a handful of microseconds later, in some other block of code, you need to check on the value in that same variable, how do you do that? As I've tried to point out in this article, the answer to that question can be quite tricky.

Collapse
tbroyer profile image
Thomas Broyer

So the question becomes, if you have a scenario in React where you've set a state variable on one line, and then, a handful of microseconds later, in some other block of code, you need to check on the value in that same variable, how do you do that?

Well, I would say you try hard not to get into that situation. And I believe there are ways to avoid that situation that would also be more idiomatic; by thinking differently about your problem.

I think the crux is to definitely stop thinking about events and "when you set X" or "when you change state".

If you need to compute state that depends on other state, then try storing coarser values into state (your whole form as one object) and pass a function to the state setter, or deriving at rendering time and memoizing.

Collapse
sirseanofloxley profile image
Sean Allin Newell • Edited

I think Vue's state pattern ia basically your custom hook but 'directly' on the trait; ie the getter/setter is how you access and set things in Vue. I think this is how that new hotness framework works... Uh... Hrmm.. svelte! That's the one. In vue (and knockout) i think there's a concept called a computed prop that is also similar to this problem. However, in knockout at least, it was limited such that you couldn't make cycles.

Could this also be solved by just making the validations also async?

Collapse
bytebodger profile image
Adam Nathaniel Davis Author • Edited

One of my previous approaches was making the validations async. That... worked. But I don't consider it to be ideal. Not that I have any problem with the concept of async/await, but it's kinda like a virus that spreads beyond its originally-intended borders, cuz await must be inside async. And once you make a function async, it returns a promise, that (depending upon your project and your inspections) either should or must be handled. Which too often leads to making the caller async, which in turn leads to the caller's caller being made async...

Before you know it, every dang function is async - which makes everything kinda messy for no good reason.

Collapse
sirseanofloxley profile image
Collapse
isaachagoel profile image
Isaac Hagoel

Just a small point about states vs. refs. Not sure how helpful in your case (need to see the JSX).
I see devs reaching out to useState for every variable they want to store (because the functional componenet runs everything from the top every time and normal variables don't persist).
The thing is, states are tied to React's rendering cycle. In other words, only use states when the desired effect of changing the value is a re-render (applies to using states within custom hooks as well).
If all you need is a variable that persists between renders but doesn't need to trigger re renders, a ref is the way to go (and as you mentioned it updates like a normal variable because it is).

Collapse
bytebodger profile image