Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Jon Rimmer
Jon Rimmer

Posted on

     

A React hook to handle state with dependencies

In order to experiment with React's new hooks API, I've been building an app calledFretfull, which lets you explore different ways to play chords on guitar. The app'ssource code uses hooks throughout for context and state.

While building the app, I created something I call a "dependent state" custom hook. It's useful when you have a piece of state with a range of valid values that are calculated based on one or more dependencies. If a dependency changes, the state's current value may no longer be valid, and will need to be checked and possibly reset.

To make that more concrete, consider the following situation:

  1. An app receives a list of product categories from the server.
  2. The app displays the list of categories in the UI.
  3. The user selects a category.
  4. The app receives an updated list of categories from the server.

At this point, the selected category may or may not be valid, depending on whether it still exists in the list of updated categories. Therefore, the app needs to be smart about how it applies the update. If the category no longer exists, keeping it selected will result an inconsistent and invalid application state. However, automatically resetting it will result in a poor user experience if the categoryis still valid. The code will need to check the updated list and reset the selectiononly if the selection is not found.

Let's consider how we might implement this scenario using React hooks.

functionCategories({apiData}:{apiData:CategoriesApiResult}){constcategories=useMemo(()=>{returnapiData.data.map(cat=>cat.name);},[apiData]);const[category,setCategory]=useState(categories[0]);return<OptionListoptions={categories}selected={category}onSelect={e=>setCategory(e.value)}/>;}

Here the Categories component creates the list of category options by mapping over the data from an API call received as a prop. We memoize the calculation so it is only executed when the API data changes. We also store the selected category as a piece of state, defaulting it to the first category in the list.

However, this code has a bug: Ifcategories changes, the value ofcategory may no longer be valid. We need to check that it's still valid, and optionally reset it. We can do this as follows:

let[category,setCategory]=useState(null);constcategories=useMemo(()=>{constresult=apiData.data.map(cat=>cat.name);if(!result.includes(category){setCategory(category=result[0]);}},[apiData]);

Now we avoid the bug, but at the expense of muddying up our render logic. We have to makecategory reassignable, define it beforecategories, and include a side effect in ourcategories memoization function that resetscategory.

We can make this approach cleaner and more reusable by implementing a custom hook, that we'll calluseDependentState:

functionuseDependentState<S>(factory:(prevState?:S)=>S,inputs:ReadonlyArray<any>,):[S,Dispatch<SetStateAction<S>>]{let[state,setState]=useState<S>(factory());useMemo(()=>{constnewState=factory(state);if(newState!==state){setState(state=newState);}},inputs);return[state,setState];}

This hook captures the essence of the above logic in a generic form. It defines a piece of state and run a memoized function that runs only when the dependencies change. This memoized function delegates to a factory function that we must provide, and which is responsible for either generating the initial value or changing the current value if it's no longer valid. Let's see how we could use it in the previous example:

constcategories=useMemo(()=>{returnapiData.data.map(cat=>cat.name);},[apiData]);const[category,setCategory]=useDependentState(prevState=>{return(prevState&&categories.includes(prevState))?prevState:categories[0];},[categories]);

Our custom hook means we can keepcategory as a const, keep the original definition order, and the only logic we have to implement is the check as to whether theprevState value is still valid.

Conclusion

Hopefully this custom hook may prove useful to anyone facing a similar issue with state whose validity depends on some dependencies.

The only downside I see to this custom hook is that it has to callsetState to update the state value when it changes, which will result in a second render. But I can't see any way to avoid this. I havesubmitted a React feature suggestion with the idea of enhancing the regularsetState hook with the ability to provide dependencies that cause it to be re-initialised in a similar manner to this custom hook. If implemented, this would eliminate the need for the additional render, as theprevState value would not have "leaked", because the check logic would occur within theuseState call.

Top comments(8)

Subscribe
pic
Create template

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

Dismiss
CollapseExpand
 
guico33 profile image
guico33
  • Joined
• Edited on• Edited

I believe what you're doing here in an anti-pattern and should be avoided altogether.
In the first code snippet, instead of the category itself, storing only the index in state is enough to avoid the problem of a potentially stale category when the props change.
Generally, you don't want to compute the state from the props, that's error prone and most of the time not needed.
Furthermore,useMemo should be used to compute a memoized value and not to perform side-effects, for that purposeuseEffect should be used.
The official doc on hooks is very clear about it.
Also,setCategory(category = result[0]) andsetState(state = newState): that's not how state updaters should be called. If anything,setCategory(result[0]) andsetState(newState) would be correct. Thecategory andstate variables shouldn't be reassigned (usingconst instead oflet will enforce that).

CollapseExpand
 
jonrimmer profile image
Jon Rimmer
I ❤️ coding
  • Location
    London, UK
  • Education
    University of Essex
  • Joined

Hi Guico33, thanks for your input. I considered your suggested approach while writing the article, but storing the selected index in state does not avoid the problem. In fact, it makes the problem worse. Consider:

constoptions=useMemo(()=>{...});const[selectedIndex,setSelectedIndex]=useState(0);constselectedOption=options[selectedIndex];

Here, whenoptions changes,selectedIndex may no longer point to a valid entry in the options array at all, or it may point to a completely different item, which will result in the UI abruptly changing to a different category. You cannot fix this, because you cannot know what itemselectedIndex pointed to during previous renders. That is why you have to keep the item itself as piece of state as well.

I understand your points re. running side effects inuseMemo and reassigning the state variables, but so long as this is contained within theuseDependentState custom hook, it shouldn't pose any danger. We could useuseEffect instead ofuseMemo, except that it would result in a first-pass render with a stale value, which might or might not be acceptable, because theuseEffect callback does not run until after the render has completed. If you're concerned with not breaking the "rules" of hooks, you can write the custom hook as follows:

exportfunctionuseDepState<S>(factory:(prevState?:S)=>S,inputs:ReadonlyArray<any>,):[S,Dispatch<SetStateAction<S>>]{const[state,setState]=useState<S>(factory());useEffect(()=>{constnewState=factory(state);if(newState!==state){setState(newState);}},inputs);return[state,setState];}

However, this does have the problem of producing a stale render, as mentioned. Personally, I'm fine with the ignoring the "rules", if I'm doing so consciously, and in a way I know won't break anything.

CollapseExpand
 
guico33 profile image
guico33
  • Joined
• Edited on• Edited

There's a reason whileuseMemo shouldn't be used, it runs while rendering, thus updating the state in it might not trigger a rerender and result in a stale UI.
I agree using the index may not be a safe choice, a better solution would be to use an id of some sort.
If the corresponding item is no longer there, you can reset the state to the id of the first item for instance, in auseEffect hook.
Only gotcha is if you want to keep track of an item which is no longer present in the data passed as props. Assuming the data is a list of options fetched remotely, it's unlikely you want the user to select an item which is no longer in the list returned by the server.

Thread Thread
 
jonrimmer profile image
Jon Rimmer
I ❤️ coding
  • Location
    London, UK
  • Education
    University of Essex
  • Joined

Even with an id, you still have the issue that, for that render, the id state value is no longer valid. This means you have to deal with checking the validity of the id twice, both in the effect, and in the render method, and the resulting code is pretty ugly and unintuitive, IMO:

constoptions=useMemo(()=>{...},[apiData]);const[selectedId,setSelectedId]=useState(options[0].id);useEffect(()=>{if(!isValidId(options,selectedId){setSelectedId(options[0].id);}},[options]);letselectedOption;if(!isValidId(options,selectedId){selectedOption=getById(options,selectedId);}else{selectedOption=options[0];}

In this case, the fact thatuseMemo runs immediately is to our benefit, because wedo want to update the state value immediately, because otherwise we're going to render invalid data. By both calling the state setter and reassigning the current state value, we avoid any problems with either invalid or stale renders. And wrapping this logic up inside a custom hook ensures that the mutability of the state variable is never leaked to the outside code.

I agree this isn't a 100% ideal solution, but I have used it and it works, without any issues regarding stale UI. However, it would be nicer ifuseState supported these kind of dependencies directly, which is why I raised the feature request I mentioned with the React team.

Thread Thread
 
guico33 profile image
guico33
  • Joined
• Edited on• Edited

The code does not need to be so verbose, it boils down to:

constoptions=useMemo(()=>{/*...*/},[apiData]);const[selectedId,setSelectedId]=useState(options[0].id);constselectedOption=options.find(({id})=>selectedId===id);useEffect(()=>{if(!selectedOption){setSelectedId(options[0].id);}},[options]);

selectedOption may not be defined so you need to account for that in yourrender method, but that's about the same as every time you're fetching data in a react component.
The computation of the options is a good use case foruseMemo.
selectedOption could use it as well, though for both it remains an optimisation and wouldn't make a difference in most cases.

Thread Thread
 
jonrimmer profile image
Jon Rimmer
I ❤️ coding
  • Location
    London, UK
  • Education
    University of Essex
  • Joined
• Edited on• Edited

This code will render withselectedOption as null for the render before the one scheduled by the effect. What if you can't do this? You will need to add something like:

constactuallySelectedOption=selectedOption||options[0];`
Thread Thread
 
guico33 profile image
guico33
  • Joined
• Edited on• Edited

In my previous comment:
selectedOption may not be defined so you need to account for that in your render method.
Defaulting it to the first option works as well. One could argue that the source of truth should be the id in state so you might wanna wait until the state has been updated and until then render something else (likely all this will be too fast to be noticeable by the user), though in the end I guess it's about the same.

CollapseExpand
 
tettoffensive profile image
Stuart Tett
Designer • Developer • Founder
  • Location
    Portland, OR
  • Work
    Co-Founder at Tusk Legacy, Inc.
  • Joined

I don't know whether this is still an anti-pattern or not, but i'm trying to ship an MVP and this was the answer I needed to get it to work, so thank you!

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

I ❤️ coding
  • Location
    London, UK
  • Education
    University of Essex
  • Joined

More fromJon Rimmer

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