Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Synchronous State With React Hooks
Adam Nathaniel Davis
Adam Nathaniel Davis

Posted on • Edited on

     

Synchronous State With React Hooks

[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 ofstate 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 solvedALL 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 theMember ID field, it callsupdateMemberId(). Then it callsvalidateMemberId(), to see if we should show any error messages to the user. Then it callsvalidateForm(), to see if we should be checking all of the other fields on the form.

So the code ends up looking something like this:

exportdefaultfunctionApp(){const[memberId,setMemberId]=useState('');const[validateEntireForm,setValidateEntireForm]=useState(false);constupdateMemberId=userValue=>{setMemberId(userValue);validateMemberId();if(validateEntireForm)validateForm();}constvalidateForm=()=>{if(!validateEntireForm)setValidateEntireForm(true);validateMemberId();// validate the rest of the fields in the form}constvalidateMemberId=()=>{// validate based on the CURRENT value of 'memberId'returnvalidOrNot;}return(<>UXHere...</>);}
Enter fullscreen modeExit 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 thememberId field triggersupdateMemberId(), which in turn updates thestate value ofmemberId, which then leads to callingvalidateMemberId(). InsidevalidateMemberId(), we'll be referencing thestate value formemberId - the value that was set microseconds previously insideupdateMemberId().

Of course, even though the value of thememberId state variable was updated duringupdateMemberId(), what happens whenvalidateMemberId() tries to reference that same variable? That's right, it doesn't see thelatest value ofmemberId. In fact, it sees whatever was saved intomemberId during theprevious update. SovalidateMemberId() is always one updatebehind.

Of course, this problem is only exacerbated if we've flipped thevalidateEntireForm flag. Because oncevalidateForm() gets called, it will also lead to referencing the value ofmemberId - which will still be stuck on theprevious value.

The "problem" is pretty simple - and one that has been inherent in React since it was created. State updates areasynchronous. 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.

SincesetMemberId() is asynchronous, subsequent references tomemberId don't reflect the most up-to-date value that was just entered by the user. They reference theprevious 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 intoone 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 itintovalidateMemberId(). But I don't like that. Why??? Well, because in this example, the state variable is thesystem of record. In other words, I don't wantvalidateMemberId() to only validate whatever value was blindly passed into it. I want that function to validatethe current state value. And if that's to occur, the function should always be looking back intostate 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 Ireally dislike feeling compelled to convert most of my calls touseState() intouseReducer(). Once you go down theuseReducer() path, more and moreand 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. UseuseRef()?? 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. IfuseRef() is the answer to this problem, I'dlove to see it. But so far... it seems lacking.

  5. UseuseEffect() Stop. No, seriously. Just...stahp. I've seen several threads on the interwebs suggesting that the "solution" to this quandary is to leverageuseEffect(). The idea is that, for example, when we want to updatememberId, we also create a call touseEffect() that handles all of the side effects that happen once we updatememberId. 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 changingone state value forces me to check on the values ofseveral 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 theverbose version of the state variable'sset function. This was the avenue I pursued for a while. But it can get, well...ugly. Consider this:

constupdateMemberId=asyncuserValue=>{letlatestMemberId;awaitsetMemberId(userValue=>{latestMemberId=userValue;returnuserValue;});validateMemberId();if(validateEntireForm)validateForm();}
Enter fullscreen modeExit fullscreen mode

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


Alt Text

The Problem - Simplified

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

constsomeFunction=someValue=>{setSomeStateVariable(someValue);if(someConditionBasedOnSomeStateVariable){//...won't trigger based on the new value of 'someStateVariable'}callAFollowOnMethod();}constcallAFollowOnMethod=()=>{if(someStateVariable)//...won't recognize the latest value of 'someStateVariable'}
Enter fullscreen modeExit fullscreen mode

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

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

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

To be absolutely clear, I fully understand thatsome things will always be, andshould always be, asynchronous. For example, if you have three state variables that hold the responses that come back from three consecutive API calls, thenof 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 readimmediately. In other words, if you can do this:

letfoo=1;console.log(foo);// 1foo=2;console.log(foo);// 2
Enter fullscreen modeExit 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);// 1setFoo(2);console.log(foo);// 1
Enter fullscreen modeExit 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 theabsolute latest state value.

  2. I'd really like to have the new state valuereturned to me after state updates. This may not seem like that big-of-a-deal - but sometimes, I really wish that the built-inset() functions would simply return the new value to me. (Of course, theycan'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';exportdefaultfunctionuseTrait(initialValue){const[trait,updateTrait]=useState(initialValue);letcurrent=trait;constget=()=>current;constset=newValue=>{current=newValue;updateTrait(newValue);returncurrent;}return{get,set,}}
Enter fullscreen modeExit 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 HookuseSynchronousState 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:

constSomeComponent=()=>{constcounter=useTrait(0);constincrement=()=>{console.log('counter =',counter.get());// 0constnewValue=counter.set(counter.get()+1);console.log('newValue =',newValue);// 1console.log('counter =',counter.get());// 1}return(<>Counter:{counter.get()}<br/><buttononClick={increment}>Increment</button></>);return(<>UXHere...</>);}
Enter fullscreen modeExit 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 ofcurrent. 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 thelatest 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 atruly 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 simplyset() 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 leastsomewhat inefficient. For every "trait" that's saved, there are essentiallytwo values being tracked. In the vast majority of code, trying to fix this "issue" would be a micro-optimization. But there are certainlysome bulky values that should not be chunked into memory twice, merely for the convenience of being able to immediately retrieve the result ofset() 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 withuseReducer(). I'm not going to try to argue them off that cliff. Similarly, the Children of Hooks would probably try to address this withuseEffect(). Personally, Ihate 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'tgrokked 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..."

Top comments(16)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
havespacesuit profile image
Eric Sundquist
I like Dad music.
  • Location
    Kansas City
  • Work
    Web Developer 👌 at Bentley Systems
  • Joined

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

constvalidateMemberId=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 setreturnvalidOrNot;},[memberId]);
Enter fullscreen modeExit fullscreen mode

It has downsides because now you need to wrapvalidateForm,updateMemberId and on up through the call chain withuseCallback 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!

CollapseExpand
 
bytebodger profile image
Adam Nathaniel Davis
React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Email
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined
• Edited on• Edited

Great feedback! I've onlyread aboutuseCallback(). 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 thatuseCallback() is a better alternative thanuseEffect(). And I'm thinking that, in some other scenarios whereuseEffect() drives me nuts, it might be because I should really be usinguseCallback()...

CollapseExpand
 
tbroyer profile image
Thomas Broyer
Developer and architect mainly interested in web development (frontend, Web APIs), web app security, build tools, Java, Kotlin, Gradle, etc.
  • Location
    Dijon, France
  • Joined

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.

CollapseExpand
 
bytebodger profile image
Adam Nathaniel Davis
React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Email
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined

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 canalways do that. For example, there are times when the validation logic is sufficiently complex that I absolutely don'twant for it to run at render time. Because if you run it at render time, that means it's going to runon every re-render.

Of course, you make a good reference touseMemo(). 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 variablefeels 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, inany 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.

CollapseExpand
 
tbroyer profile image
Thomas Broyer
Developer and architect mainly interested in web development (frontend, Web APIs), web app security, build tools, Java, Kotlin, Gradle, etc.
  • Location
    Dijon, France
  • Joined

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 hardnot 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 aboutevents 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.

CollapseExpand
 
isaachagoel profile image
Isaac Hagoel
  • Location
    Sydney, Australia
  • Work
    Principal Engineer at Atlassian (ex. Pearson, TripAdvisor, Intel)
  • Joined

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).

CollapseExpand
 
bytebodger profile image
Adam Nathaniel Davis
React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Email
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined

Excellent point!

CollapseExpand
 
sirseanofloxley profile image
Sean Allin Newell
A lifelong learner seeking to serve and help where he can
  • Location
    Edinburgh, Scotland
  • Education
    BS in CS from UTD
  • Work
    EM / Staff Engineer at Administrate
  • Joined
• Edited on• 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?

CollapseExpand
 
bytebodger profile image
Adam Nathaniel Davis
React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Email
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined
• Edited on• 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 ofasync/await, but it's kinda like a virus that spreads beyond its originally-intended borders, cuzawaitmust be insideasync. And once you make a functionasync, it returns a promise, that (depending upon your project and your inspections) eithershould ormust be handled. Which too often leads to making the callerasync, which in turn leads to the caller's caller being madeasync...

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

CollapseExpand
 
sirseanofloxley profile image
Sean Allin Newell
A lifelong learner seeking to serve and help where he can
  • Location
    Edinburgh, Scotland
  • Education
    BS in CS from UTD
  • Work
    EM / Staff Engineer at Administrate
  • Joined

Agreed.

CollapseExpand
 
drodsou profile image
drodsou
  • Joined

Regarding useRef, I have this thing, even though I'm completely bypassing React state and just using it as a kind of forceUpdate.

gist.github.com/drodsou/b947eb192d...

CollapseExpand
 
lazee profile image
Jakob Vad Nielsen
  • Joined

Great article! Helped me solve a problem here now and saved me from a lot of headache.

CollapseExpand
 
bytebodger profile image
Adam Nathaniel Davis
React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Email
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined

Thank you - I'm so glad that it helped!

CollapseExpand
 
lazee profile image
Jakob Vad Nielsen
  • Joined
exportconstuseSyncState=<T>(initialState:T):[()=>T,(newState:T)=>void]=>{const[state,updateState]=useState(initialState)letcurrent=stateconstget=():T=>currentconstset=(newState:T)=>{current=newStateupdateState(newState)returncurrent}return[get,set]}
Enter fullscreen modeExit fullscreen mode

A generic variant for TypeScript if anybody is interested.

Thread Thread
 
bytebodger profile image
Adam Nathaniel Davis
React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Email
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined

Awesome - thank you!

CollapseExpand
 
nxcco profile image
Nico
  • Location
    Germany
  • Pronouns
    he/him
  • Joined

Thank you for writing about this problem in detail. And also thank you for creating this NPM package, you made my day!

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

React acolyte, jack-of-all-(programming)trades, full-stack developer
  • Location
    New Orleans, LA
  • Work
    Frontend Software Engineer
  • Joined

More fromAdam Nathaniel Davis

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp