Skip to content

fix: Address eslint react-hook violations in CharacterCount#3321

Merged
brandonlenz merged 3 commits intotrussworks:mainfrom
mdmower-csnw:mdm-charcount
Nov 13, 2025
Merged

fix: Address eslint react-hook violations in CharacterCount#3321
brandonlenz merged 3 commits intotrussworks:mainfrom
mdmower-csnw:mdm-charcount

Conversation

@mdmower-csnw
Copy link
Contributor

@mdmower-csnw mdmower-csnw commented Nov 11, 2025

Summary

  • Fix eslint react-hooks violation "Calling setState synchronously within an effect can trigger cascading renders" by replacing useEffect with state updates during render, as described in Adjusting some state when a prop changes.
  • Set initialCount once and don't keep reevaluating it in each render.
  • Minor cleanup in blur and change handlers (value cannot be undefined for text area and text input.

How To Test

  1. Verify no regressions in storybook CharacterCount stories.
  2. The hook lint errors can be seen by temporarily enabling react-hooks linting:
    diff --git a/eslint.config.cjs b/eslint.config.cjs
    index fa6b8bc..a13ab1c 100644
    --- a/eslint.config.cjs
    +++ b/eslint.config.cjs
    @@ -31,6 +31,7 @@ module.exports = defineConfig([
             'plugin:import/warnings',
             'plugin:import/typescript',
             'plugin:react/recommended',
    +        'plugin:react-hooks/recommended',
             'plugin:jsx-a11y/recommended',
             'plugin:security/recommended-legacy',
             'plugin:storybook/recommended',

@mdmower-csnw mdmower-csnw requested a review from a team as a code owner November 11, 2025 03:30
@mdmower-csnw mdmower-csnw changed the title fix: Address eslint react-hook violatoins in CharacterCount fix: Address eslint react-hook violations in CharacterCount Nov 11, 2025
const message = getMessage(length, maxLength)
setMessage(message)
const [prevLength, setPrevLength] = useState(length)
if (length !== prevLength) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the current implementation, only updating the message and validation status when the length changes. A more complete revision would respect an isolated change to the getMessage prop. It's a bit of an edge case, but not completely unrealistic:

<CharacterCount
  getMessage={new Date() >= modern_requirements_date ? modernMessageMaker : legacyMessageMaker}
  ...props
>

...remainingProps
}: TextInputCharacterCountProps | TextareaCharacterCountProps): JSX.Element => {
const initialCount = getCharacterCount(value || defaultValue)
const [initialCount] = useState(getCharacterCount(value || defaultValue))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine, so not blocking, but out of curiosity: Why not use a ref here?

Something like

Suggested change
const [initialCount] = useState(getCharacterCount(value || defaultValue))
const initialCount = useRef(null)
if (intialCount.current === null) {
intialCount.current = getCharacterCount(value || defaultValue)
}

Admittedly, it looks way grosser, but is supposedly more semantically correct. Found some relevant discussion/examples here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brandonlenz - Argh, neither of our approaches is correct here. I'm glad you left a comment.

My approach failed at the one improvement it was supposed to achieve: only evaluate getCharacterCount(value || defaultValue) once for the purpose of setting initialCount during the initial render. Use that value for the next few useState() lines and then forget about it. Because I passed getCharacterCount(value || defaultValue) as a value to useState() rather than through an initializer function, it still gets evaluated every render. My change only achieved adding tiny overhead to each render cycle.

Your approach does better at only evaluating getCharacterCount(value || defaultValue) once like intended (kudos for spotting that and not assigning the value as the useRef() argument). Unfortunately, it violates another principle: ref values are not supposed to be accessed during renders. Enabling react-hooks recommended eslint ruleset notes this:

Error: Cannot access refs during render

React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the current property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef)

In 9d33668, I switch to using initializer functions for this setState and also for the message setState.

@brandonlenz
Copy link
Contributor

The hook lint errors can be seen by temporarily enabling react-hooks linting

This is a rule we can enable, if you would prefer!

@mdmower-csnw
Copy link
Contributor Author

This is a rule we can enable, if you would prefer!

Enabling the ruleset reveals dozens of violations. Some are challenging to solve. I was planning to peck away at them over a long period of time. If CI doesn't depend on an error free lint report, sure enabling the ruleset would be nice. Otherwise, I'm happy to keep temporarily enabling the rules as I find motivation.

@brandonlenz brandonlenz self-requested a review November 13, 2025 14:26
Copy link
Contributor

@brandonlenz brandonlenz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking the time to reevaluate that logic! LGTM now

@brandonlenz brandonlenz enabled auto-merge (squash) November 13, 2025 14:31
@brandonlenz brandonlenz merged commit d1d8f55 into trussworks:main Nov 13, 2025
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants